Partial texture updates at runtime

Hey everyone,

I have particular situation where I need to improve the startup time of a PlayCanvas app that has many texture atlases.
The optimal way we think would work best for us is to create the atlases at runtime dynamically like this:

  1. generate an offline atlas with Texture Packer or Free Texture Packer apps and only use the JSON data inside PC as a preloaded JSON resource
  2. add all the sprite PNGs inside PC as texture resources (streamed not preloaded)
  3. at runtime, based on the offline created atlas info (JSON at pt. 1), we create an empty pc.TextureAtlas of the required size
  4. we then start async loading of sprite PNGs from pt. 2 and when each one is loaded we would like to partially update the texture atlas’s texture with the new sprite at the given location from the JSON info. We then release the sprite texture as it is not needed anymore, it should be available in the atlas.

What this means is that we would need to be able to partially update a pc.Texture.
Is that possible?
Looking at the implementation of the pc.Texture I see that one possible way would be to use the pc.Texture.lock() method (taken from the comments there)

 * // Fill the texture with a gradient
 * var pixels = texture.lock();
 * var count = 0;
 * for (var i = 0; i < 8; i++) {
 *     for (var j = 0; j < 8; j++) {
 *         pixels[count++] = i * 32;
 *         pixels[count++] = j * 32;
 *         pixels[count++] = 255;
 *     }
 * }
 * texture.unlock();

but looking at the lock implementation it seems like it always recreates a buffer for the whole texture (not to mention that updating pixels one by one in JS might be really slow). What I would need is something like a wrapper over gl.texSubImage2D and I found a call to this in only one place in the engine for updating the mini-stats.

Sounds like it would be better to generate the atlas on an offscreen canvas, then create a texture from the canvas itself.

I don’t think that would work, I need to use the atlas even if it only has a single sprite added, your suggestion implies that I draw all the sprites to the canvas and create the atlas as a final step.

You could create a new texture after a bunch of images have loaded, then do it again for the next bunch.

There is also an example here where a texture is applied to another texture that might help? Character Damage Demo | Learn PlayCanvas

I think this is sub-optimal, I was wondering if you guys would accept a PR with API additions (some overloads maybe) to pc.Texture to allow partial updates based on WebGLRenderingContext.texSubImage2D() or WebGLRenderingContext.copyTexSubImage2D() (for in-GPU texture transfer) I think this would benefit PC on the long run. If that’s the case I can work on an implementation and discuss with you guys how that API would best look. @will what’s your take on this?

1 Like

At the moment I’m working on the cookie texture support for clustered lights, and the way I do it is to generate and update the atlas based on visible lights. I start with an empty atlas like you, and have other textures I need to place into rectangular areas of that atlas.

So I’ve created a RenderTarget for this texture, that allows me to render to it. And I use a simple shader and render individual textures into it.

Here’s my work in progress code, but it’s pretty much done and works well.

import { Vec4 } from '../../math/vec4.js';

import { ADDRESS_CLAMP_TO_EDGE, FILTER_NEAREST, PIXELFORMAT_R8_G8_B8_A8 } from '../../graphics/constants.js';
import { Texture } from "../../graphics/texture.js";
import { createShaderFromCode } from '../../graphics/program-lib/utils.js';
import { drawQuadWithShader } from '../../graphics/simple-post-effect.js';

const textureBlitVertexShader = `
    attribute vec2 vertex_position;
    varying vec2 uv0;
    void main(void) {
        gl_Position = vec4(vertex_position, 0.5, 1.0);
        uv0 = vertex_position.xy * 0.5 + 0.5;
    }`;

const textureBlitFragmentShader = `
    varying vec2 uv0;
    uniform sampler2D blitTexture;
    void main(void) {
        gl_FragColor = texture2D(blitTexture, uv0);
    }`;

const _viewport = new Vec4();

class CookieRenderer {
    constructor(device) {
        this.device = device;
        this.blitShader = null;
        this.blitTextureId = device.scope.resolve("blitTexture");
    }

    destroy() {
    }

    get shader() {

        if (!this.blitShader) {
            this.blitShader = createShaderFromCode(this.device, textureBlitVertexShader, textureBlitFragmentShader, "cookieTextureBlitShader");
        }

        return this.blitShader;
    }

    render(light, renderTarget) {

        if (light.enabled && light.cookie && light.visibleThisFrame) {

            // #if _DEBUG
            this.device.pushMarker("COOKIE " + light._node.name);
            // #endif

            const shader = this.shader;
            const device = this.device;

            const faceCount = light.numShadowFaces;
            for (let face = 0; face < faceCount; face++) {

                // source texture
                this.blitTextureId.setValue(light.cookie);

                // render it to the viewport in the target
                const lightRenderData = light.getRenderData(null, face);
                _viewport.copy(lightRenderData.shadowViewport).mulScalar(renderTarget.colorBuffer.width);
                drawQuadWithShader(device, renderTarget, shader, _viewport);
            }

            // #if _DEBUG
            this.device.popMarker();
            // #endif
        }
    }
}
3 Likes

Thanks @mvaligursky , I’ll give it a try with this approach and see how that goes. I had a look on how the texture is uploaded inside GraphicsDevice::uploadTexture and also looked inside the Texture implementation and adding partial updates is not that trivial especially if one considers the mipmaps as well. Your suggestion seems straightforward

2 Likes

Came back with results, this works fine for my needs, thanks guys :wink:

2 Likes