Unable to apply outline post-effect in model viewer

Hello, gurus! I want to apply outline post-effect in model-viewer, but to no avail. This is what I’ve done so far. I’ve implemented OutlineEffect class itself:

class OutlineEffect extends PostEffect
{
    private shader: Shader;
    private normalsTexture: any;
    private options: any;
    private outlineOnly: any;
    private outlineColor: any;
    private multiplierParameters: any;

    constructor(device: GraphicsDevice, normalsTexture: any, options: any) {
        super(device);

        this.normalsTexture = normalsTexture;
        this.outlineOnly = options.outlineOnly;
        var color = options.outlineColor;
        this.outlineColor = [color.r, color.g, color.b];
        this.multiplierParameters = [
            options.depthBias,
            options.depthMultiplier, 
            options.normalBias, 
            options.normalMultiplier
        ];
        this.needsDepthBuffer = true;

        const vshader = `
            attribute vec2 aPosition;
            varying vec2 vUv0;

            void main(void)
            {
                gl_Position = vec4(aPosition, 0.0, 1.0);
                vUv0 = (aPosition.xy + 1.0) * 0.5;
            }
        `;

        const fshader = `
        precision ${device.precision} float;
        ${device.isWebGL2 ? '#define GL2' : ""}
        ${shaderChunks.screenDepthPS}

        varying vec2 vUv0;
        uniform sampler2D uColorBuffer;
        uniform sampler2D uNormalBuffer;
        uniform bool uOutlineOnly;
        uniform vec4 uMultiplierParameters;
        uniform vec3 uOutlineColor;

        // Helper functions for reading normals and depth of neighboring pixels.
        float getPixelDepth(float x, float y) {
            // uScreenSize.zw is pixel size 
            // vUv0 is current position
            return getLinearScreenDepth(vUv0 + uScreenSize.zw * vec2(x, y));
        }
        vec3 getPixelNormal(int x, int y) {
            return texture2D(uNormalBuffer, vUv0 + uScreenSize.zw * vec2(x, y)).rgb;
        }
        float saturateValue(float num) {
            return clamp(num, 0.0, 1.0);
        }

        void main()
        {
            // Color, depth, and normal for current pixel.
            vec4 sceneColor = texture2D( uColorBuffer, vUv0 );
            float depth = getPixelDepth(0.0, 0.0);
            vec3 normal = getPixelNormal(0, 0);

            // Get the difference between depth of neighboring pixels and current.
            float depthDiff = 0.0;
            depthDiff += abs(depth - getPixelDepth(1.0, 0.0));
            depthDiff += abs(depth - getPixelDepth(-1.0, 0.0));
            depthDiff += abs(depth - getPixelDepth(0.0, 1.0));
            depthDiff += abs(depth - getPixelDepth(0.0, -1.0));

            // Get the difference between normals of neighboring pixels and current
            float normalDiff = 0.0;
            normalDiff += distance(normal, getPixelNormal(1, 0));
            normalDiff += distance(normal, getPixelNormal(0, 1));
            normalDiff += distance(normal, getPixelNormal(0, 1));
            normalDiff += distance(normal, getPixelNormal(0, -1));

            normalDiff += distance(normal, getPixelNormal(1, 1));
            normalDiff += distance(normal, getPixelNormal(1, -1));
            normalDiff += distance(normal, getPixelNormal(-1, 1));
            normalDiff += distance(normal, getPixelNormal(-1, -1));

            // Apply multiplier & bias to each 
            float depthBias = uMultiplierParameters.x;
            float depthMultiplier = uMultiplierParameters.y;
            float normalBias = uMultiplierParameters.z;
            float normalMultiplier = uMultiplierParameters.w;

            depthDiff = depthDiff * depthMultiplier;
            depthDiff = saturateValue(depthDiff);
            depthDiff = pow(depthDiff, depthBias);

            normalDiff = normalDiff * normalMultiplier;
            normalDiff = saturateValue(normalDiff);
            normalDiff = pow(normalDiff, normalBias);

            float outline = normalDiff + depthDiff;
            
            // Combine outline with scene color.
            vec4 outlineColor = vec4(uOutlineColor, 1.0);
            gl_FragColor = vec4(mix(sceneColor, outlineColor, outline));

            if (uOutlineOnly) {
                gl_FragColor = vec4(vec3(uOutlineColor * outline), 1.0);
            }

            // Uncomment to debug draw either the normal buffer  
            // or the depth buffer.
            //gl_FragColor = vec4(normal, 1.0);
            //gl_FragColor = vec4(vec3(depth * 0.0005), 1.0);
            //gl_FragColor = vec4(vec3(depthMultiplier), 1.0);
        }`;

        this.shader = createShaderFromCode(device, vshader, fshader, 'OutlineDetectShader', {
            aPosition: SEMANTIC_POSITION
        });
    }

    render(inputTarget: any, outputTarget: any, rect: any) {
        var device = this.device;
        var scope = device.scope;

        // This contains the scene color.
        scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);
        
        // This is the scene re-rendered with a normal material on every mesh.
        scope.resolve("uNormalBuffer").setValue(this.normalsTexture);
        
        // Parameters for styling this effect. 
        scope.resolve("uOutlineOnly").setValue(this.outlineOnly);
        scope.resolve("uMultiplierParameters").setValue(this.multiplierParameters);
        scope.resolve("uOutlineColor").setValue(this.outlineColor);

        console.log(scope.resolve("uColorBuffer"), scope.resolve("uNormalBuffer"));

        this.drawQuad(outputTarget, this.shader, rect);        
    }
}

export { OutlineEffect }

The whole solution is based on this work. Next, in viewer class I’m building a normals pass layer like this:

const normalPassLayer = new Layer({
            name: 'NormalsPass'
        });
        app.scene.layers.insert(normalPassLayer, 0); //0

        // Make all meshes & lights render into the normals pass. 
        this.getAllEntities(this.app.root, node => {
            if (node.model != undefined) {
                node.model.layers = [...node.model.layers, normalPassLayer.id];
            }
            if (node.light != undefined) {
                node.light.layers = [...node.light.layers, normalPassLayer.id];
            }
        });

        // Make this layer set a normal material on all meshes
        // and turn it off post render. 
        normalPassLayer.onPreRender = () => {
            this.toggleNormalMaterial(true);
        };
        normalPassLayer.onPostRender = () => {
            this.toggleNormalMaterial(false);
        };

        const nt = new Texture(app.graphicsDevice, {
            name: "normals",
            width: Math.floor(app.graphicsDevice.width),
            height: Math.floor(app.graphicsDevice.height),
            format: PIXELFORMAT_R8_G8_B8_A8,
            mipmaps: false
        });
        
        const rt = new RenderTarget({
            colorBuffer: nt,
            name: "normals"
        });

        // Create a second camera that will render the normals 
        // onto the normal layer we created above.
        this.normalsCamera = new Entity("normals-camera");
        this.normalsCamera.addComponent('camera');
        this.normalsCamera.camera.layers = [normalPassLayer.id];
        this.normalsCamera.camera.renderTarget = rt;

        app.root.addChild(this.normalsCamera);
        this.normalsCamera.camera.clearColor = Color.BLACK;

Adopted toggleNormalMaterial method looks like:

private toggleNormalMaterial(bool: boolean) {
        // Replace the material on all opaque meshes in the "World"
        // layer with our normal material.
        var worldLayer = this.app.scene.layers.getLayerByName("World");
        for (let mesh of worldLayer.meshInstances) {
            if (!mesh.transparent) {
                if (mesh.originalMaterial == undefined) {
                    mesh.originalMaterial = mesh.material;
                }
                if (bool) {
                    mesh.material = this.getNormalMaterial();
                } else {    
                    mesh.material = mesh.originalMaterial;                  
                }
            }
        }
    }

And finally I have a button, that toggles this outline post-effect:

this.outlineEffect = new OutlineEffect(
      this.app.graphicsDevice,
      this.normalsCamera.camera.renderTarget.colorBuffer,
      {
          outlineOnly: true,
          outlineColor: Color.BLACK,
          depthBias: 1,
          depthMultiplier: 1,
          normalBias: 1,
          normalMultiplier:1
      }
  );
  var queue = this.camera.camera.postEffects;
  queue.addEffect(this.outlineEffect);

It looks like I have all necessary pieces to make it work - 1) I have a layer that stores normals texture, I guess, 2) I have a separate camera that renders this texture 3) I supply OutlineEffect with this normals texture as a color buffer 4) I finally add this outline effect to the queue of the main camera, so that outline shader has access both to scene and normals texture to make its calculations. However, when I run it all, I see no effect applied. In OutlineEffect render method I added debug statement that dumps scene color buffer and normals buffer to the console, but I do not know what should I pay attention to. Probably, the only interesting thing that I see, is that width and height properties of scene buffer and normals buffer differ significantly. But I’m not sure whether it is important or not. You can see the demo here and to run it you need to toggle outline button:

Thank you for your attention! If you know how can I possibly fix it or just to debug it, you are welcome!

I would suggest you to install the Spector JS extension to your browser, capture a frame and inspect what is happening.

1 Like

Unfortunatelly, the page crushes, every time I try to debug anything with SpectorJS in Chrome. I push the red button and everything halts and crushes after some time

I tried to apply all possible post-effects in model-viewer, none of them works. I see a blink and the model remains the same

The model viewer does not render unless the camera moves, or the scene is animated). It renders a frame and then waits for some change.

So when you press the capture button of Spector, click and drag on the 3d viewport to make the camera change trigger the rendering.

1 Like

That is the point. I hit the capture button, then go to canvas and it halts - it can not move, until page crushes

Can you capture this?

I checked it - yes, I can capture this. It works.