Requirements
Render a very large geometry (>1 million triangles) via webgl, and via user interaction change the color of some of the triangles. 60fps should be maintained. The geometry cannot be simplified, should be rendered as is, with all the triangles.
The problem
The render cycle takes too much time, sometimes up to 100ms.
What I've tried
I've tried rendering the scene via THREE.js buffered geometry, grouping the triangles which share the same colour (as long as their indices are in a row) and reusing the materials via materialIndex
of the groups.
//Declare three.js variables
let camera, scene, renderer, mesh
//assign three.js objects to each variable
function init() {
//camera
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight);
camera.position.z = 2000;
//scene
scene = new THREE.Scene();
//renderer
renderer = new THREE.WebGLRenderer({
antialias: true
});
//set the size of the renderer
renderer.setSize(window.innerWidth, window.innerHeight);
//add the renderer to the html document body
document.querySelector('.webgl').appendChild(renderer.domElement);
}
function addMesh() {
const bufferGeometries = []
for (var i = 0; i < 50000; i++) {
var geo = new THREE.BoxGeometry(15, 15, 15)
geo.applyMatrix4(new THREE.Matrix4().makeTranslation(Math.random() * 1500 - 500, Math.random() * 1500 - 500, 0));
geo.rotateX(Math.random() * 1)
geo.rotateY(Math.random() * 1)
bufferGeometries.push(new THREE.BufferGeometry().fromGeometry(geo))
}
const mergedBufferGeometries = mergeBufferGeometries(bufferGeometries, true)
mesh = new THREE.Mesh(mergedBufferGeometries, new THREE.MeshNormalMaterial());
mesh.material = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((value) => new THREE.MeshPhongMaterial({
emissive: new THREE.Color(`hsl(${value * i}, 100%, 50%)`),
side: THREE.DoubleSide,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
transparent: false,
depthWrite: false
}))
mesh.geometry.groups = mesh.geometry.groups.map(group => ({
...group,
materialIndex: 0
})) // assign the first material too all the groups
scene.add(mesh);
}
window.animate = function() {
mesh.geometry.groups = mesh.geometry.groups.map((group, i) =>
i < 100 ? // assign random materials to the first 1,00 items
({
...group,
materialIndex: Math.floor(Math.random() * 10)
}) :
group
)
performance.mark('a');
render()
performance.measure('duration', 'a')
const entries = performance.getEntriesByType("measure")
document.getElementById('time').innerText = Math.round(entries[0].duration)
performance.clearMeasures()
performance.clearMarks()
}
function render() {
//render the scene
renderer.render(scene, camera);
}
init();
addMesh();
render();
window.animate();// assign random colors one first time
// below is copied from https://github.com/mrdoob/three.js/blob/dev/examples/js/utils/BufferGeometryUtils.js
function mergeBufferAttributes(attributes) {
var TypedArray;
var itemSize;
var normalized;
var arrayLength = 0;
for (var i = 0; i < attributes.length; ++i) {
var attribute = attributes[i];
if (attribute.isInterleavedBufferAttribute) {
console.error('THREE.BufferGeometryUtils: .mergeBufferAttributes() failed. InterleavedBufferAttributes are not supported.');
return null;
}
if (TypedArray === undefined) TypedArray = attribute.array.constructor;
if (TypedArray !== attribute.array.constructor) {
console.error('THREE.BufferGeometryUtils: .mergeBufferAttributes() failed. BufferAttribute.array must be of consistent array types across matching attributes.');
return null;
}
if (itemSize === undefined) itemSize = attribute.itemSize;
if (itemSize !== attribute.itemSize) {
console.error('THREE.BufferGeometryUtils: .mergeBufferAttributes() failed. BufferAttribute.itemSize must be consistent across matching attributes.');
return null;
}
if (normalized === undefined) normalized = attribute.normalized;
if (normalized !== attribute.normalized) {
console.error('THREE.BufferGeometryUtils: .mergeBufferAttributes() failed. BufferAttribute.normalized must be consistent across matching attributes.');
return null;
}
arrayLength += attribute.array.length;
}
var array = new TypedArray(arrayLength);
var offset = 0;
for (var i = 0; i < attributes.length; ++i) {
array.set(attributes[i].array, offset);
offset += attributes[i].array.length;
}
return new THREE.BufferAttribute(array, itemSize, normalized);
}
function mergeBufferGeometries(geometries, useGroups) {
var isIndexed = geometries[0].index !== null;
var attributesUsed = new Set(Object.keys(geometries[0].attributes));
var morphAttributesUsed = new Set(Object.keys(geometries[0].morphAttributes));
var attributes = {};
var morphAttributes = {};
var morphTargetsRelative = geometries[0].morphTargetsRelative;
var mergedGeometry = new THREE.BufferGeometry();
var offset = 0;
for (var i = 0; i < geometries.length; ++i) {
var geometry = geometries[i];
var attributesCount = 0;
// ensure that all geometries are indexed, or none
if (isIndexed !== (geometry.index !== null)) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.');
return null;
}
// gather attributes, exit early if they're different
for (var name in geometry.attributes) {
if (!attributesUsed.has(name)) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.');
return null;
}
if (attributes[name] === undefined) attributes[name] = [];
attributes[name].push(geometry.attributes[name]);
attributesCount++;
}
// ensure geometries have the same number of attributes
if (attributesCount !== attributesUsed.size) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed with geometry at index ' + i + '. Make sure all geometries have the same number of attributes.');
return null;
}
// gather morph attributes, exit early if they're different
if (morphTargetsRelative !== geometry.morphTargetsRelative) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed with geometry at index ' + i + '. .morphTargetsRelative must be consistent throughout all geometries.');
return null;
}
for (var name in geometry.morphAttributes) {
if (!morphAttributesUsed.has(name)) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed with geometry at index ' + i + '. .morphAttributes must be consistent throughout all geometries.');
return null;
}
if (morphAttributes[name] === undefined) morphAttributes[name] = [];
morphAttributes[name].push(geometry.morphAttributes[name]);
}
// gather .userData
mergedGeometry.userData.mergedUserData = mergedGeometry.userData.mergedUserData || [];
mergedGeometry.userData.mergedUserData.push(geometry.userData);
if (useGroups) {
var count;
if (isIndexed) {
count = geometry.index.count;
} else if (geometry.attributes.position !== undefined) {
count = geometry.attributes.position.count;
} else {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed with geometry at index ' + i + '. The geometry must have either an index or a position attribute');
return null;
}
mergedGeometry.addGroup(offset, count, i);
offset += count;
}
}
// merge indices
if (isIndexed) {
var indexOffset = 0;
var mergedIndex = [];
for (var i = 0; i < geometries.length; ++i) {
var index = geometries[i].index;
for (var j = 0; j < index.count; ++j) {
mergedIndex.push(index.getX(j) + indexOffset);
}
indexOffset += geometries[i].attributes.position.count;
}
mergedGeometry.setIndex(mergedIndex);
}
// merge attributes
for (var name in attributes) {
var mergedAttribute = mergeBufferAttributes(attributes[name]);
if (!mergedAttribute) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed while trying to merge the ' + name + ' attribute.');
return null;
}
mergedGeometry.setAttribute(name, mergedAttribute);
}
// merge morph attributes
for (var name in morphAttributes) {
var numMorphTargets = morphAttributes[name][0].length;
if (numMorphTargets === 0) break;
mergedGeometry.morphAttributes = mergedGeometry.morphAttributes || {};
mergedGeometry.morphAttributes[name] = [];
for (var i = 0; i < numMorphTargets; ++i) {
var morphAttributesToMerge = [];
for (var j = 0; j < morphAttributes[name].length; ++j) {
morphAttributesToMerge.push(morphAttributes[name][j][i]);
}
var mergedMorphAttribute = mergeBufferAttributes(morphAttributesToMerge);
if (!mergedMorphAttribute) {
console.error('THREE.BufferGeometryUtils: .mergeBufferGeometries() failed while trying to merge the ' + name + ' morphAttribute.');
return null;
}
mergedGeometry.morphAttributes[name].push(mergedMorphAttribute);
}
}
return mergedGeometry;
}
html, body {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
}
.webgl {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r119/three.min.js"></script>
<div class="webgl"></div>
<button onClick='window.animate()'>refresh colors</button>
<div style='color: white'>requied time <span id='time'>ms</span></div>
Ideal solution
Rerender only the triangles/pixels needed, where the colour changes. I've tried to read through THREE.js documentation for partial rendering, but to no avail. I've only found setDrawRange
which renders only certain indices, but actually "unrenders" the rest which isn't what I want.