Performant ways to render many images on planes in a scene

I have a project that needs to load many images (dozens to hundreds) on planes positioned in a scene. I’m getting the image data as Base64. I can think of two ways to do this, and they both work well.

  1. Use the Base64 data as a texture, then add the texture to a material, add the material to the plane.
  2. Use the Base64 on an Image in an Advanced Dynamic texture.

Both ideas work well, but I’m curious which one would have better performance? My guess is that option one should perform better, but maybe there is some optimization in ADT that might speed it up?

propbably best to atlas the textures so you can use minimal materials , otherwise every unique material will result in a draw call, one per material.

I don’t think that is going to be viable. The images are generated dynamically by an app and passed to to JS/Babylon to render.

it is possible to handle runtime atlasing , anyway it is not a simple solution , just what I thought of since you mentioned you want solutions presented to be performant.

Most modern hardware will handle 100’s of draw calls , anyway other than this you probably should also look at using thin instances for the mesh objects.

  1. Do you know image sizes beforehand?
  2. I believe you may put some load into worker.

It sounds like the text on a material approach is going to be better than using images on ADTs. The developer who generates the image was able to create a texture atlas version. There was some work to do to turn the coordinates he sent me into UV data, but it totally worked.

This is very much a work in progress, but it lets us use a single material for all the meshes.

import * as BABYLON from "@babylonjs/core";
import { useDataStore } from "../composables/DataStore";
import { watch } from "vue";

const { clipboardDataFlat, clipboardDataDictionary, setMainSelectionID, mainSelectionID, textureAtlasImage } = useDataStore();

export class LLProcessor {
  private scene: BABYLON.Scene;
  private layoutMat!: BABYLON.StandardMaterial;
  private selectMat!: BABYLON.StandardMaterial;
  private textureAtlas: BABYLON.Texture | null = null;

  constructor(scene: BABYLON.Scene) {
    this.scene = scene;
    this.initializeLayoutMat();
  }

  private initializeLayoutMat(): void {
    this.layoutMat = new BABYLON.StandardMaterial("timeline-material", this.scene);
    this.layoutMat.alpha = 0.75;
    this.layoutMat.backFaceCulling = false;

    this.selectMat = new BABYLON.StandardMaterial("timeline-material", this.scene);
    this.selectMat.diffuseColor = BABYLON.Color3.FromHexString("#818CF8");
    this.selectMat.specularColor = new BABYLON.Color3(0.2, 0.2, 0.2);
  }

  clean() {
    this.scene.meshes.forEach((mesh) => {
      if (mesh.name !== "grid") {
        mesh.dispose();
      }
    });
  }

  generate() {
    if (textureAtlasImage.value) {
      const b64String = `data:image/png;base64,${textureAtlasImage.value}`;

      this.textureAtlas = new BABYLON.Texture(b64String, this.scene);
      this.textureAtlas.hasAlpha = true;

      this.layoutMat.diffuseTexture = this.textureAtlas;
      this.layoutMat.emissiveTexture = this.textureAtlas;
      this.layoutMat.useAlphaFromDiffuseTexture = true;

      this.textureAtlas.onLoadObservable.add(() => {
        clipboardDataFlat.forEach((element: any) => {
          this.createLayerBox(element, this.scene);
        });
      });
    }
  }

  // TODO Conssider a custom type or interface for the clipboard data instead of using any
  private createLayerBox = (item: any, scene: BABYLON.Scene) => {
    const offset = 100;
    const parentID = item.idParent;
    const parent = (clipboardDataDictionary as any)[parentID];
    const width = (item.boundsRight - item.boundsLeft) / offset;
    const height = (item.boundsBottom - item.boundsTop) / offset;

    let posX = item.boundsLeft; // get the item's left position
    if (parent) {
      posX += parent.boundsLeft; // add the parent's left position
    }
    posX /= offset; // divide by the offset
    posX += width / 2; // add half the width

    let posY = item.boundsTop; // get the item's top position
    if (parent) {
      posY += parent.boundsTop; // add the parent's top position
    }
    posY /= offset; // divide by the offset
    posY += height / 2; // add half the height

    let posZ = item.containmentDepth + item.renderDepth / 100;

    if (this.textureAtlas) {
      const atlasSize = this.textureAtlas.getSize(); // Get the size of the texture atlas

      const { upperLeftX, upperLeftY, lowerRightX, lowerRightY } = item.textureCoordinates;
      const u1 = upperLeftX / atlasSize.width;
      const v1 = 1 - upperLeftY / atlasSize.height; // v coordinates are flipped
      const u2 = lowerRightX / atlasSize.width;
      const v2 = 1 - lowerRightY / atlasSize.height; // v coordinates are flipped

      console.log(item.name, `u1: ${u1}, v1: ${v1}, u2: ${u2}, v2: ${v2}`);

      // Create a custom mesh with the calculated UV coordinates
      // The UV coordinates are assigned in the order: bottom-left, bottom-right, top-right, top-left
      const verticesData = [
        -width / 2,
        -height / 2,
        0,
        u1,
        v2, // bottom-left
        width / 2,
        -height / 2,
        0,
        u2,
        v2, // bottom-right
        width / 2,
        height / 2,
        0,
        u2,
        v1, // top-right
        -width / 2,
        height / 2,
        0,
        u1,
        v1 // top-left
      ];
      const indices = [0, 1, 2, 0, 2, 3];
      const mesh = new BABYLON.Mesh(item.id, scene);
      const vertexData = new BABYLON.VertexData();
      vertexData.positions = verticesData.filter((_, i) => i % 5 < 3);
      vertexData.indices = indices;
      vertexData.uvs = verticesData.filter((_, i) => i % 5 >= 3);
      vertexData.applyToMesh(mesh);

      // Set the position, rotation and material of the mesh
      mesh.position.x = -posX;
      mesh.position.y = -posY;
      mesh.position.z = posZ;
      mesh.rotation.y = Math.PI;
      mesh.material = this.layoutMat;

      // Add an action manager to the mesh
      const am = new BABYLON.ActionManager(scene);
      mesh.actionManager = am;
      mesh.actionManager.registerAction(
        new BABYLON.ExecuteCodeAction(BABYLON.ActionManager.OnPickTrigger, () => {
          setMainSelectionID(item.id);
        })
      );
    }

    // Watch mainSelectionID for changes
    watch(mainSelectionID, (newValue, oldValue) => {
      // get mesh by id: old value
      const oldMesh = scene.getMeshById(oldValue)! as BABYLON.Mesh;
      if (oldMesh) {
        oldMesh.showBoundingBox = false;
      }
      const oldMat = scene.getMaterialByName(oldValue)! as BABYLON.StandardMaterial;
      if (oldMat) {
        oldMat.alpha = 0.75;
      }

      // get mesh by id: new value
      const newMesh = scene.getMeshById(newValue);
      if (newMesh) {
        newMesh.showBoundingBox = true;
        // newMesh.material?.alpha = 1;
        const camera = scene.activeCamera as BABYLON.ArcRotateCamera;
        if (camera) {
          camera.setTarget(newMesh);
        }
      }

      const newMat = scene.getMaterialByName(newValue)! as BABYLON.StandardMaterial;
      if (newMat) {
        newMat.alpha = 1;
      }
    });
  };
}

1 Like

Hi!

You can use GreasedLine for this:

5000 images in 1 draw call. The texture atlas is created dynamically at startup. It loads 16 random 64x64 images from an API. I takes a while to download them.

Without BILLBOARD_MODE:

I had to make a change to the GreasedLineMesh class. It’s in draft and not merged yet but you can try out in the playground snapshot created from the pull request:

500 images version:

1500 images version:

You should propably initialize the mesh in lazy mode when dealing with large number of line segments in GRL (just add these two lines)

Non camera facing version of GreasedLineMesh:

This is just a POC not a final solution.

Some parameters you might want to try to change:

    const TEXTURE_ATLAS_IMAGE_COUNT = 16
    const TEXTURE_ATLAS_WIDTH = 64 * TEXTURE_ATLAS_IMAGE_COUNT
    const TEXTURE_ATLAS_HEIGHT = 64

    const IMAGE_MIN_X = -20
    const IMAGE_MAX_X = 20
    const IMAGE_MIN_Y = -20
    const IMAGE_MAX_Y = 20
    const IMAGE_MIN_Z = -80
    const IMAGE_MAX_Z = 0

    const IMAGE_COUNT = 1500

    //

    mesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL

    scene.onBeforeRenderObservable.add(() => {
        mesh.rotate(BABYLON.Axis.Z, 0.01 * scene.getAnimationRatio())
    })

You can use points offsets to transform each image separatelly ofcourse and to use everything what GRL supports (color modes, tintig, dashes, …)

I will propose a Texture Atlas feature to the BJS team for building atlases dynamically, Link will be added here afterwards.

:vulcan_salute: