three-instanced-mesh icon indicating copy to clipboard operation
three-instanced-mesh copied to clipboard

Automatically set needsUpdate with correct updateRange

Open DVLP opened this issue 6 years ago • 4 comments
trafficstars

Currently using setPositon requires manually executing needsUpdate(). It would be more performant to narrow down only the needed positions that require updating by utilising updateRange and this could be calculated automatically when setting positions before each render

DVLP avatar Apr 25 '19 23:04 DVLP

Can you make an example of how this would work. I'm actually not that familiar with update range, i'd have to research it a bit. How would it work if you don't have a sparse list of instances?

pailhead avatar May 13 '19 01:05 pailhead

When setting needsUpdate, updateRange is used to limit the update only to given range of elements. i.e. there are 50 instances and when you update instance 31 you don't want to update them all just instance 31 so you set update range { offset: 30 * attribute.itemSize, count: 1 * attribute.itemSize }

Here's the implementation that works in my project. btw thanks for making this library and it helps with instancing a lot! I'm good in JS but a beginner/semi-intermediate with shaders. I'm using it to draw tire tracks in my game engine http://bad.city

InstancedMesh.prototype.setUpdateRange = function (attribute, instanceNumber) {
  const count = 1;
  const start = instanceNumber;
  if (!attribute.needsUpdate) {
    attribute.updateRange.offset = start * attribute.itemSize;
    attribute.updateRange.count = count * attribute.itemSize;
  } else {
    // if there's already an element to udpate increase the updateRange to contain from first to last one
    const firstStart = Math.min(start, attribute.updateRange.offset);
    const lastStart = Math.max(start, attribute.updateRange.offset, attribute.updateRange.offset + attribute.updateRange.count - 1);
    attribute.updateRange.offset = firstStart * attribute.itemSize;
    attribute.updateRange.count = (lastStart - firstStart + 1) * attribute.itemSize;
  }
}

InstancedMesh.prototype.needsUpdate = function( attribute, instanceNumber ){

  switch ( attribute ){

    case 'position' :

      this.setUpdateRange(this.geometry.attributes.instancePosition, instanceNumber);
      this.geometry.attributes.instancePosition.needsUpdate =   true;

      break;

    case 'quaternion' :

      this.setUpdateRange(this.geometry.attributes.instanceQuaternion, instanceNumber);
      this.geometry.attributes.instanceQuaternion.needsUpdate = true;

      break;

    case 'scale' :

      this.setUpdateRange(this.geometry.attributes.instanceScale, instanceNumber);
      this.geometry.attributes.instanceScale.needsUpdate =      true;

      break;

    case 'colors' :

      this.setUpdateRange(this.geometry.attributes.instanceColor, instanceNumber);
      this.geometry.attributes.instanceColor.needsUpdate =      true;

      break;

    default:

      this.setUpdateRange(this.geometry.attributes.instancePosition, instanceNumber);
      this.setUpdateRange(this.geometry.attributes.instanceQuaternion, instanceNumber);
      this.setUpdateRange(this.geometry.attributes.instanceScale, instanceNumber);

      this.geometry.attributes.instancePosition.needsUpdate =   true;
      this.geometry.attributes.instanceQuaternion.needsUpdate = true;
      this.geometry.attributes.instanceScale.needsUpdate =      true;

      if(this._colors){
        this.setUpdateRange(this.geometry.attributes.instanceColor, instanceNumber);
        this.geometry.attributes.instanceColor.needsUpdate =      true;
      }

      break;

  }

};

DVLP avatar Jun 07 '19 14:06 DVLP

I found a problem when setting an updateRange multiple times before the renderer sends the changes to the GPU. In the code below all the fixes are contained to not loose any update ranges even if they are done multiple times before rendering. It's using onAfterRender to flag if an attribute updateRange should be extended or if it's just after the render it can be completely overwritten.

I also added support for objects with multiple materials. Surprisingly it worked! Now entire wheels with 3 materials each are instances in my engine. So 4 wheels x 3 draw calls = 12 is now reduced to 3 draw calls. Nice.

Probably I should make a PR but I'm not sure if you want to convert this project to ES6 (I'm using individual imports instead of i.e. THREE.BuffferGeometry).

import { MeshDepthMaterial, RGBADepthPacking, ShaderLib, UniformsUtils, ShaderMaterial, Mesh, InstancedBufferGeometry, Vector3, Quaternion, InstancedBufferAttribute } from '../js/_THREE';
import './InstancedMeshPatch/index';

/**************************
 * Dusan Bosnjak @pailhead
 **************************/


//depth mat
var DEPTH_MATERIAL = new MeshDepthMaterial();

DEPTH_MATERIAL.depthPacking = RGBADepthPacking;

DEPTH_MATERIAL.clipping = true;

DEPTH_MATERIAL.defines = {

  'INSTANCE_TRANSFORM': ''

};

//distance mat
var DISTANCE_SHADER = ShaderLib[ "distanceRGBA" ],
  DISTANCE_UNIFORMS = UniformsUtils.clone( DISTANCE_SHADER.uniforms ),
  DISTANCE_DEFINES = {
    'USE_SHADOWMAP': '',
    'INSTANCE_TRANSFORM': ''
  },
  DISTANCE_MATERIAL = new ShaderMaterial( {
    defines: DISTANCE_DEFINES,
    uniforms: DISTANCE_UNIFORMS,
    vertexShader: DISTANCE_SHADER.vertexShader,
    fragmentShader: DISTANCE_SHADER.fragmentShader,
    clipping: true
  })
;

//main class
export function InstancedMesh (
  bufferGeometry,
  material,
  numInstances,
  dynamic,
  colors,
  uniformScale
) {

  Mesh.call( this , (new InstancedBufferGeometry()).copy( bufferGeometry ) ); //hacky for now

  this._dynamic = !!dynamic; //TODO: set a bit mask for different attributes?

  this._uniformScale = !!uniformScale;

  this._colors = !!colors;

  this.numInstances = numInstances;

  this._setAttributes();

  /**
   * use the setter to decorate this material
   * this is in lieu of changing the renderer
   * WebGLRenderer injects stuff like this
   */
  this.material = Array.isArray(material) ? material.map(mat => mat.clone()) : material.clone();

  this.frustumCulled = false; //you can uncheck this if you generate your own bounding info

  this.lastKnownVersions = {
    'instancePosition': 0,
    'instanceQuaternion': 0,
    'instanceScale': 0,
    'instanceColor': 0,
  };

  //make it work with depth effects
  this.customDepthMaterial = DEPTH_MATERIAL;

  this.customDistanceMaterial = DISTANCE_MATERIAL;

}

InstancedMesh.prototype = Object.create( Mesh.prototype );

InstancedMesh.constructor = InstancedMesh;

//this is kinda gnarly, done in order to avoid setting these defines in the WebGLRenderer (it manages most if not all of the define flags)
Object.defineProperties( InstancedMesh.prototype , {

  'material': {

    set: function( m ){

      /**
       * whenever a material is set, decorate it,
       * if a material used with regular geometry is passed,
       * it will mutate it which is bad mkay
       *
       * either flag Material with these instance properties:
       *
       *  "i want to create a RED PLASTIC material that will
       *   be INSTANCED and i know it will be used on clones
       *   that are known to be UNIFORMly scaled"
       *  (also figure out where dynamic fits here)
       *
       * or check here if the material has INSTANCE_TRANSFORM
       * define set, if not, clone, document that it breaks reference
       * or do a shallow copy or something
       *
       * or something else?
       */
      const isArray = Array.isArray(m);
      const mats = isArray ? m.map(mat => mat.clone()) : [m.clone()];

      mats.forEach(m => {
        if ( m.defines ) {

          m.defines['INSTANCE_TRANSFORM'] = '';

          if ( this._uniformScale ) m.defines['INSTANCE_UNIFORM'] = ''; //an optimization, should avoid doing an expensive matrix inverse in the shader
          else delete m.defines['INSTANCE_UNIFORM'];

          if ( this._colors ) m.defines['INSTANCE_COLOR'] = '';
          else delete m.defines['INSTANCE_COLOR'];
        }

        else{

          m.defines = { 'INSTANCE_TRANSFORM': '' };

          if ( this._uniformScale ) m.defines['INSTANCE_UNIFORM'] = '';
          if ( this._colors ) m.defines['INSTANCE_COLOR'] = '';
        }
      })

      this._material = isArray ? mats : mats[0];

    },

    get: function(){ return this._material; }

  },

  //force new attributes to be created when set?
  'numInstances': {

    set: function( v ){

      this._numInstances = v;

      //reset buffers

      this._setAttributes();

    },

    get: function(){ return this._numInstances; }

  },

  //do some auto-magic when BufferGeometry is set
  //TODO: account for Geometry, or change this approach completely
  'geometry':{

    set: function( g ){

      //if its not already instanced attach buffers
      if ( !!g.attributes.instancePosition ) {

        this._geometry = new InstancedBufferGeometry();

        this._setAttributes();

      }

      else

        this._geometry = g;

    },

    get: function(){ return this._geometry; }

  }

});

InstancedMesh.prototype.setPositionAt = function( index , position ){

  this.geometry.attributes.instancePosition.setXYZ( index , position.x , position.y , position.z );

};

InstancedMesh.prototype.setQuaternionAt = function ( index , quat ) {

  this.geometry.attributes.instanceQuaternion.setXYZW( index , quat.x , quat.y , quat.z , quat.w );

};

InstancedMesh.prototype.setScaleAt = function ( index , scale ) {

  this.geometry.attributes.instanceScale.setXYZ( index , scale.x , scale.y , scale.z );

};

InstancedMesh.prototype.setColorAt = function ( index , color ) {

  if( !this._colors ) {

    console.warn( 'InstancedMesh: color not enabled');

    return;

  }

  this.geometry.attributes.instanceColor.setXYZ(
    index ,
    Math.floor( color.r * 255 ),
    Math.floor( color.g * 255 ),
    Math.floor( color.b * 255 )
  );

};

InstancedMesh.prototype.getPositionAt = function( index , position ){

  var arr = this.geometry.attributes.instancePosition.array;

  index *= 3;

  return position ?

    position.set( arr[index++], arr[index++], arr[index] ) :

    new Vector3(  arr[index++], arr[index++], arr[index] )
    ;

};

InstancedMesh.prototype.getQuaternionAt = function ( index , quat ) {

  var arr = this.geometry.attributes.instanceQuaternion.array;

  index = index << 2;

  return quat ?

    quat.set(       arr[index++], arr[index++], arr[index++], arr[index] ) :

    new Quaternion( arr[index++], arr[index++], arr[index++], arr[index] )
    ;

};

InstancedMesh.prototype.getScaleAt = function ( index , scale ) {

  var arr = this.geometry.attributes.instanceScale.array;

  index *= 3;

  return scale ?

    scale.set(   arr[index++], arr[index++], arr[index] ) :

    new Vector3( arr[index++], arr[index++], arr[index] )
    ;

};

InstancedMesh.prototype.getColorAt = (function(){

  var inv255 = 1/255;

  return function ( index , color ) {

    if( !this._colors ) {

      console.warn( 'InstancedMesh: color not enabled');

      return false;

    }

    var arr = this.geometry.attributes.instanceColor.array;

    index *= 3;

    return color ?

      color.setRGB( arr[index++] * inv255, arr[index++] * inv255, arr[index] * inv255 ) :

      new Vector3( arr[index++], arr[index++], arr[index] ).multiplyScalar( inv255 )
      ;

  };

})()

InstancedMesh.prototype.onAfterRender = function() {
  if (this.geometry.attributes.instancePosition) {
    this.lastKnownVersions[this.geometry.attributes.instancePosition.name] = this.geometry.attributes.instancePosition.version;
  }
    if (this.geometry.attributes.instanceQuaternion) {
    this.lastKnownVersions[this.geometry.attributes.instanceQuaternion.name] = this.geometry.attributes.instanceQuaternion.version;
  }
    if (this.geometry.attributes.instanceScale) {
    this.lastKnownVersions[this.geometry.attributes.instanceScale.name] = this.geometry.attributes.instanceScale.version;
  }
    if (this.geometry.attributes.instanceColor) {
    this.lastKnownVersions[this.geometry.attributes.instanceColor.name] = this.geometry.attributes.instanceColor.version;
  }
};

InstancedMesh.prototype.setUpdateRange = function (attribute, instanceFrom, instanceTo) {
  // no params given, update all
  if (instanceFrom === undefined) {
    attribute.updateRange.offset = 0;
    attribute.updateRange.count = -1;
    return;
  }

  // only instanceFrom given - automatically update including all changes before renderer updated attribute's version
  const start = instanceFrom * attribute.itemSize;
  const count = (instanceTo || 1) * attribute.itemSize;
  if (this.lastKnownVersions[attribute.name] === attribute.version) {
    attribute.updateRange.offset = start;
    attribute.updateRange.count = count;
  } else {
    // if there's already an element to udpate increase the updateRange to contain from first to last one
    const rangeStart = Math.min(start, attribute.updateRange.offset);
    const rangeEnd = Math.max(start + count, attribute.updateRange.offset + attribute.updateRange.count);
    attribute.updateRange.offset = rangeStart;
    attribute.updateRange.count = rangeEnd - rangeStart;
  }
}

InstancedMesh.prototype.needsUpdate = function( attribute, instanceFrom, instanceTo ){

  switch ( attribute ){

    case 'position' :

      this.setUpdateRange(this.geometry.attributes.instancePosition, instanceFrom, instanceTo);
      this.geometry.attributes.instancePosition.needsUpdate =   true;

      break;

    case 'quaternion' :

      this.setUpdateRange(this.geometry.attributes.instanceQuaternion, instanceFrom, instanceTo);
      this.geometry.attributes.instanceQuaternion.needsUpdate = true;

      break;

    case 'scale' :

      this.setUpdateRange(this.geometry.attributes.instanceScale, instanceFrom, instanceTo);
      this.geometry.attributes.instanceScale.needsUpdate =      true;

      break;

    case 'colors' :

      this.setUpdateRange(this.geometry.attributes.instanceColor, instanceFrom, instanceTo);
      this.geometry.attributes.instanceColor.needsUpdate =      true;

      break;

    default:

      this.setUpdateRange(this.geometry.attributes.instancePosition, instanceFrom, instanceTo);
      this.setUpdateRange(this.geometry.attributes.instanceQuaternion, instanceFrom, instanceTo);
      this.setUpdateRange(this.geometry.attributes.instanceScale, instanceFrom, instanceTo);

      this.geometry.attributes.instancePosition.needsUpdate =   true;
      this.geometry.attributes.instanceQuaternion.needsUpdate = true;
      this.geometry.attributes.instanceScale.needsUpdate =      true;

      if(this._colors){
        this.setUpdateRange(this.geometry.attributes.instanceColor, instanceFrom, instanceTo);
        this.geometry.attributes.instanceColor.needsUpdate =      true;
      }

      break;

  }

};

InstancedMesh.prototype._setAttributes = function(){

  var normalized = true
  var meshPerAttribute = 1
  var vec4Size = 4
  var vec3Size = 3

  var attributes = {
    instancePosition: [
      new Float32Array( this.numInstances * vec3Size ),
      vec3Size,
      !normalized,
      meshPerAttribute,
    ],
    instanceQuaternion: [
      new Float32Array( this.numInstances * vec4Size ),
      vec4Size,
      !normalized,
      meshPerAttribute,
    ],
    instanceScale: [
      new Float32Array( this.numInstances * vec3Size ),
      vec3Size,
      !normalized,
      meshPerAttribute,
    ]
  }

  if ( this._colors ){
    attributes.instanceColor = [
      new Uint8Array( this.numInstances * vec3Size ),
      vec3Size,
      normalized,
      meshPerAttribute,
    ]
  }

  Object.keys(attributes).forEach(name=>{
    const a = attributes[name];
    // for version above 96
    // const attribute = new InstancedBufferAttribute(...a);
    const attribute = new InstancedBufferAttribute(a[0],a[1],a[3]);
    attribute.normalized = a[2];
    attribute.name = name;

    attribute.dynamic = this._dynamic;
    this.geometry.addAttribute(name, attribute)
  })

};

DVLP avatar Jun 07 '19 15:06 DVLP

@DVLP

Do you have time to maybe discuss this over slack or something?

I'm thinking of some kind of autoUpdate flag, either depending on the scenegraph auto-updating or its own. I'm not sure what to do when you update sparse instances. Say if you call set on instances 0-15 individually, before you update, you could have one range from 0-15. But as soon as you update 20, you have another range 20-20. Worse case scenario odds would be updated evens would not. What would be a sensible way to distinguish if it would be faster updating several ranges or just the whole thing?

pailhead avatar Nov 16 '19 22:11 pailhead