Vertex shader to prevent instances to be affected by camera zoom and rotation

Hi all!

I’m using InstancedMesh and BatchedMesh to display thousands (even dozens/hundreds of thousands) of labels I use for the UI of my project.

To be able to use them as intended I need them to look always the same size, always facing the camera and aligned to the x/y axis of the screen (similar to how it would work with an html layer on top).
Until now I used two scenes and synchronized the positions of the objects in one scene with the labels in the other one, but that CPU sync is bottlenecking the performance and I desperately need to prevent it.

My scene uses an OrthographicCamera, and to zoomIn/Out I change the .zoom property of it.

How could I modify the default project_vertex shader to accomplish that?

I’ve checked the code of the Sprite material that does something similar to what I need but I’m having big trouble to make it work with InstancedMesh/BatchedMesh…, also doesn’t have the feature to ‘ignore’ the zoom.

I don’t use OrthographicCamera and zoom, but maybe this can be helpful…

I have a custom InstacedSprite class for labels, which extends InstancedMesh and uses a simple PlaneGeometry.

Then in the vertex shader I have something like this:

vec4 mvPosition = viewMatrix * modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);

vec2 scale = scale * -mvPosition.z; // scale is a vec2 uniform/const
vec2 center = vec2(-0.5, 0.0); // anchor point
vec2 alignedPosition = (position.xy - center) * scale;

mvPosition.xy += alignedPosition;

gl_Position = projectionMatrix * mvPosition;

Probably this can be simplified, there are lots of other stuff going on in the shader.

@electric.cicada
Thanx a lot for your suggestion but it doesn’t seem to work for me.
The resulting vertex positions don’t seem to be right, maybe it’s because it’s an orthographic camera or because my instanced/batched meshes are children of other groups.

The billboard effect works as expected, but I’m not sure about the zoom effect. You may need to adjust the zoomMultiplier uniform.

1 Like

Thx a lot for your help @Fennec !

Yeah I see your examples work pretty well for the billboard effect, but the InstancedMesh is a direct child of the scene. What if the InstancedMesh is a child of another Group (or several other nested groups) having those parents their own translations/rotations?
That is the case in my app, maybe that’s the problem I’m encountering? How to account for that in the vertex shader?

1 Like

I knew something was off, and I remember the headache I had when working on something similar. The problem isn’t just getting the positions right (the mesh and camera’s world positions), but also the rotation. Even when it’s done correctly, the instances stay rotated. You’ll need to adjust the rotation to keep them upright.

I’ve never done this with a shader, so I’m not sure if this is the best approach, but it seems to be working.

1 Like

Thanks a lot @Fennec! , I have it almost working thx to your last awesome codepen.

I had to make a couple of modifications:

  • I had to rename some of the variables in the shader as they are re-declared later in other chunks and the shader compilation was failing (maybe because of the specific way I extend the shader in my project, I’m surprised it doesn’t throw errors in the codepen).
  • I had to remove the last line with transformed += worldPosition.xyz; was applying the translation of the parent twice, as I think the parent world matrix is applied later in the project_vertex.glsk.js chunk.

With that I have it almost working. My last struggle is the value of that zoomMultiplier constant…
By a lot of trial and error it seems a value of 0.0006 makes the labels the size I want, though not exactly, and I fear not understanding it will hit me in the face later on.
Could you explain what this constant means/do? Can it be calculated more precisely with some equation using the settings of the camera or something like that??

Thx!

1 Like

Nice catch!

I was more focused on keeping my head straight while working on the nested rotations and keeping the instances upright :upside_down_face:

The zoomMultiplier was meant to be an adjustable scale factor, I couldn’t get the right zoom-to-scale ratio. But it turns out the solution is as simple as:

float scale = 1.0 / cameraZoom;

Here’s a cleaner version. I have to say, I’m glad I got involved with this thread, what started as a quick hack turned into a useful instanced billboard solution :blush:

2 Likes

@Fennec First of all, all my thanks for your help, I couldn’t have pulled it off without you. One million thanks for putting the time for it, I hope this also helps and benefits you somehow (and all the community :slight_smile: ).

Before all this I was rendering all the labels I needed in a secondary scene, computing the necessary un-projections/projections and it worked fine, but it was bottle-necking the CPU like crazy as I had to sync everything every frame, and when I had several thousand objects with labels I was hitting a hard ceiling performance wise.

But now that we made it work the problem is forever gone. In my specific use case it was quite hard as I don’t have just InstancedMeshes but also BatchedMeshes and even some special custom InstancedText meshes extended from the super cool troika-three-text library.

To explain it a bit, my labels are made of:

  • A shape that ends looking as the outline, they are a rounded rect so each one is a different geometry in a BatchedMesh.
  • A shape over the previous one that looks as the actual background of the label, also a a rounded rect so also a geometry in a BatchedMesh.
  • An optional Icon that has a ‘local’ offset (local relative to the rendered label) to fit in the right place with the text of the label. These are an InstancedMesh.
  • The text of the label, using troika-three-text. These are most similar to an InstancedMesh.

Theory is fine, but this is the result (sorry for potato quality but the video has to fit 8Mb):

And as I always try to do in every thread I open, here is the complete working solution.
All the next code is meant to work with the awesome three-extended-material, which is the solution I use to be able to chain several custom shader modifications.

This is my final working extended shader code that I use in my InstancedMeshes:

import { GridThree } from '@/three/GridThree';

export const ExtensionLabelVertex = {
    name: "extension-label-vertex",
    uniforms: {
      billboardMatrix: GridThree.billboardMatrix,
      cameraZoom: GridThree.cameraZoom
    },
    vertexShader: (shader: any) => {
      shader = `
        uniform mat4 billboardMatrix;
        uniform float cameraZoom;

        attribute vec2 instLocalOffset;

        ${
            shader.replace(
              `#include <begin_vertex>`,
              `
              #include <begin_vertex>
            
              vec3 toCamera = normalize((billboardMatrix * vec4(0, 0, 1, 0)).xyz);
              vec3 up = (billboardMatrix * vec4(0, 1, 0, 0)).xyz; 
              vec3 right = normalize(cross(up, toCamera));
              vec3 forward = cross(right, up);
            
              mat3 billboardRotation = mat3(right, up, forward);
            
              float scale = 1.0 / cameraZoom;
            
              // Apply the offset before transforming the position
              transformed.x += instLocalOffset.x;
              transformed.y -= instLocalOffset.y;

              transformed = billboardRotation * transformed * scale;
              `
            )
        }
      `;
      return shader;
    },
};

And this is the extended shader code I use for my BatchedMeshes:

import * as THREE from 'three';
import { GridThree } from '@/three/GridThree';

export const ExtensionLabelVertexBatched = {
    name: "extension-label-vertex-batched",
    uniforms: {
      localOffsetsTexture: new THREE.Texture(),
      billboardMatrix: GridThree.billboardMatrix,
      cameraZoom: GridThree.cameraZoom
    },
    vertexShader: (shader: any) => {
      shader = `
        uniform sampler2D localOffsetsTexture; // Define local offsets texture
        uniform mat4 billboardMatrix;
        uniform float cameraZoom;

        vec2 getBatchingOffset( const in float i ) {
          int size = textureSize( localOffsetsTexture, 0 ).x;
          int j = int( i );
          int x = j % size;
          int y = j / size;

          return texelFetch( localOffsetsTexture, ivec2( x, y ), 0 ).rg;
        }

        ${
            shader.replace(
              `#include <begin_vertex>`,
              `
              #include <begin_vertex>

              vec3 toCamera = normalize((billboardMatrix * vec4(0, 0, 1, 0)).xyz);
              vec3 up = (billboardMatrix * vec4(0, 1, 0, 0)).xyz; 
              vec3 right = normalize(cross(up, toCamera));
              vec3 forward = cross(right, up);
            
              mat3 billboardRotation = mat3(right, up, forward);
            
              float scale = 1.0 / cameraZoom;
            
              vec2 localOffset = getBatchingOffset( getIndirectIndex( gl_DrawID ) );

              // Apply the offset before transforming the position
              transformed.x += localOffset.x;
              transformed.y -= localOffset.y;

              transformed = billboardRotation * transformed * scale;
              `
            )
        }
      `;
      return shader;
    },
};

Also last but not least, this is the modification I had to do to the troika-three-text library for it to work just the same way (I copy just a part as the whole class is massive, DM me if you need the full code).

vertexTransform: `
            vec3 toCamera = normalize((billboardMatrix * vec4(0, 0, 1, 0)).xyz);
            vec3 up = (billboardMatrix * vec4(0, 1, 0, 0)).xyz; 
            vec3 right = normalize(cross(up, toCamera));
            vec3 forward = cross(right, up);
        
            mat3 billboardRotation = mat3(right, up, forward);
        
            float scale = 1.0 / cameraZoom;

            vec2 localOffset = troikaGetBatchingLocalOffset(${memberIndexAttrName});

            // Apply the offset before transforming the position
            position.x += localOffset.x;
            position.y -= localOffset.y;

            position = billboardRotation * position * scale;

            mat4 matrix = troikaGetBatchingMatrix(${memberIndexAttrName});
            position.xyz = (matrix * vec4(position, 1.0)).xyz;

            ${colorVaryingName} = troikaGetBatchingColor(${memberIndexAttrName});

            ${opacityVaryingName} = troikaGetBatchingOpacity(${memberIndexAttrName});
            `

@Fennec As you mentioned this could be a very good foundation to incorporate this ‘billboard’ material into the engine, it works perfectly with Instanced and Batches meshes, even with third party libraries. SpriteMaterial was always awesome but outdated as it doesn’t support instancing/batching.

Right now I’m absolutely drained to write it and propose a PR, also I think I lack the skill to do it so it works in all situations with N levels of parent objects…

At least I hope this post helps the community, just as @Fennec did to me, man you are the :goat: !!!

1 Like

My journey is far from finished as I now have to make these labels interactive somehow…
Which means I will spend some crazy time ‘translating’ the computations of the vertex shader to a custom raycasting function for some of these instanced meshes…
Ideally even using a BVH as I do for the rest of the ‘normal’ objects in the app.
But that’s a problem for tomorrow :grin:

If I mange to solve it I will post it in the thread for the sake of completion.

1 Like

You’re combining aesthetics with functionality, It looks like a top-down RTS game, and I love it :star_struck:. The labels look awesome, the zoom effect is smooth, and the whole thing runs well, considering the scene’s complexity.

Congrats! Looking forward to see the finished product!

2 Likes