const m4 = twgl.m4;
const v3 = twgl.v3;
const gl = document.querySelector('canvas').getContext('webgl');
const ext = gl.getExtension('OES_texture_float');
if (!ext) {
alert('need OES_texture_float');
}
const COMMON_STUFF = `
#define TEXTURE_WIDTH 5.0
#define MATRIX_ROW_0_OFFSET ((0. + 0.5) / TEXTURE_WIDTH)
#define MATRIX_ROW_1_OFFSET ((1. + 0.5) / TEXTURE_WIDTH)
#define MATRIX_ROW_2_OFFSET ((2. + 0.5) / TEXTURE_WIDTH)
#define MATRIX_ROW_3_OFFSET ((3. + 0.5) / TEXTURE_WIDTH)
#define COLOR_OFFSET ((4. + 0.5) / TEXTURE_WIDTH)
`;
const vs = `
attribute vec2 perVertexData;
uniform float perObjectDataTextureHeight; // NOTE: in WebGL2 use textureSize()
uniform sampler2D perObjectDataTexture;
uniform vec2 vertexDataTextureSize; // NOTE: in WebGL2 use textureSize()
uniform sampler2D vertexDataTexture;
uniform mat4 projection;
uniform mat4 view;
varying vec3 v_normal;
varying float v_objectId;
${COMMON_STUFF}
void main() {
float vertexId = perVertexData.x;
float objectId = perVertexData.y;
v_objectId = objectId; // pass to fragment shader
float objectOffset = (objectId + 0.5) / perObjectDataTextureHeight;
// note: in WebGL2 better to use texelFetch
mat4 model = mat4(
texture2D(perObjectDataTexture, vec2(MATRIX_ROW_0_OFFSET, objectOffset)),
texture2D(perObjectDataTexture, vec2(MATRIX_ROW_1_OFFSET, objectOffset)),
texture2D(perObjectDataTexture, vec2(MATRIX_ROW_2_OFFSET, objectOffset)),
texture2D(perObjectDataTexture, vec2(MATRIX_ROW_3_OFFSET, objectOffset)));
// note: in WebGL2 better to use texelFetch
// note: vertexId will be even numbers since there are 2 pieces of data
// per vertex, position and normal.
vec2 colRow = vec2(mod(vertexId, vertexDataTextureSize.x),
floor(vertexId / vertexDataTextureSize.x)) + 0.5;
vec2 baseUV = colRow / vertexDataTextureSize;
vec4 position = texture2D(vertexDataTexture, baseUV);
vec3 normal = texture2D(vertexDataTexture, baseUV + vec2(1) / vertexDataTextureSize).xyz;
gl_Position = projection * view * model * position;
v_normal = mat3(view) * mat3(model) * normal;
}
`;
const fs = `
precision highp float;
varying vec3 v_normal;
varying float v_objectId;
uniform float perObjectDataTextureHeight;
uniform sampler2D perObjectDataTexture;
uniform vec3 lightDirection;
${COMMON_STUFF}
void main() {
float objectOffset = (v_objectId + 0.5) / perObjectDataTextureHeight;
// maybe we should look this up in the vertex shader
vec4 color = texture2D(perObjectDataTexture, vec2(COLOR_OFFSET, objectOffset));
float l = dot(lightDirection, normalize(v_normal)) * .5 + .5;
gl_FragColor = vec4(color.rgb * l, color.a);
}
`;
// compile shader, link, look up locations
const programInfo = twgl.createProgramInfo(gl, [vs, fs]);
// make some vertex data
const modelVerts = [
twgl.primitives.createSphereVertices(1, 6, 4),
twgl.primitives.createCubeVertices(1, 1, 1),
twgl.primitives.createCylinderVertices(1, 1, 10, 1),
twgl.primitives.createTorusVertices(1, .2, 16, 8),
].map(twgl.primitives.deindexVertices);
const modelVertexCounts = [];
const modelVertexOffsets = [];
{
let offset = 0;
modelVerts.forEach((verts) => {
let vertexCount = verts.position.length / 3;
modelVertexCounts.push(vertexCount);
modelVertexOffsets.push(offset);
offset += vertexCount;
});
}
// merge all the vertices into one
const arrays = twgl.primitives.concatVertices(modelVerts);
// copy arrays into texture.
function copyPositionsAndNormalsIntoTexture(arrays) {
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const numVerts = arrays.position.length / 3;
const numPixels = numVerts * 2; // each vertex will have position and normal
const numPixelsNeeded = ((numPixels + maxTextureSize - 1) / maxTextureSize | 0) * maxTextureSize;
const data = new Float32Array(numPixelsNeeded * 4); // RGBA
for (let i = 0; i < numVerts; ++i) {
const src = i * 3;
const dst = i * 2 * 4;
data[dst ] = arrays.position[src ];
data[dst + 1] = arrays.position[src + 1];
data[dst + 2] = arrays.position[src + 2];
data[dst + 3] = 1;
data[dst + 4] = arrays.normal[src ];
data[dst + 5] = arrays.normal[src + 1];
data[dst + 6] = arrays.normal[src + 2];
data[dst + 7] = 1;
}
const height = numPixelsNeeded / maxTextureSize;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maxTextureSize, height, 0, gl.RGBA, gl.FLOAT, data);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return {
texture,
size: [maxTextureSize, height],
};
}
const vertexDataTextureInfo = copyPositionsAndNormalsIntoTexture(arrays);
let numTotalVerts = 0;
const numObjects = 2000;
const objects = [];
for (let i = 0; i < numObjects; ++i) {
const modelId = r() * modelVerts.length | 0;
numTotalVerts += modelVertexCounts[modelId];
objects.push({
modelId,
objectId: i,
});
}
// for every vertex we need 2 pieces of data
// 1. An objectId (used to look up per object data)
// 2. An vertexID (used to look up the vertex)
const perVertexData = new Uint16Array(numTotalVerts * 2);
// calls gl.createBuffer, gl.bindBuffer, gl.bufferData
const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
perVertexData: {
numComponents: 2,
data: perVertexData,
},
});
const perObjectDataTexture = gl.createTexture();
const perObjectDataTextureWidth = 5; // 4x4 matrix, 4x1 color
gl.bindTexture(gl.TEXTURE_2D, perObjectDataTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, perObjectDataTextureWidth, numObjects, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// this data is for the texture, one row per model
// first 4 pixels are the model matrix, 5 pixel is the color
const perObjectData = new Float32Array(perObjectDataTextureWidth * numObjects * 4);
const stride = perObjectDataTextureWidth * 4;
const modelOffset = 0;
const colorOffset = 16;
// set the colors at init time
for (let objectId = 0; objectId < numObjects; ++objectId) {
perObjectData.set([r(), r(), r(), 1], objectId * stride + colorOffset);
}
function r() {
return Math.random();
}
const RANDOM_RANGE = Math.pow(2, 32);
let seed = 0;
function pseudoRandom() {
return (seed =
(134775813 * seed + 1) %
RANDOM_RANGE) / RANDOM_RANGE;
}
function resetPseudoRandom() {
seed = 0;
}
function render(time) {
time *= 0.001; // seconds
twgl.resizeCanvasToDisplaySize(gl.canvas);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
const fov = Math.PI * 0.25;
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const near = 0.1;
const far = 20;
const projection = m4.perspective(fov, aspect, near, far);
const eye = [0, 0, 15];
const target = [0, 0, 0];
const up = [0, 1, 0];
const camera = m4.lookAt(eye, target, up);
const view = m4.inverse(camera);
// set the matrix for each object in the texture data
resetPseudoRandom();
const mat = m4.identity();
for (let objectId = 0; objectId < numObjects; ++objectId) {
// of course you'd probably store translation, rotation, etc per object in objects[]
const t = time * (0.3 + pseudoRandom() * 0.1) + pseudoRandom() * Math.PI * 2;
m4.identity(mat);
m4.rotateX(mat, t * 0.93, mat);
m4.rotateY(mat, t * 0.87, mat);
m4.translate(mat, [
1 + pseudoRandom() * 3,
1 + pseudoRandom() * 3,
1 + pseudoRandom() * 3,
], mat);
m4.rotateZ(mat, t * 1.17, mat);
perObjectData.set(mat, objectId * stride);
}
// set the per vertex data. (sort objects before this line)
{
let offset = 0;
for (const obj of objects) {
const numVerts = modelVertexCounts[obj.modelId];
const vertOffset = modelVertexOffsets[obj.modelId];
for (let v = 0; v < numVerts; ++v) {
perVertexData[offset++] = (vertOffset + v) * 2; // 2 is because 2 pixels per vertex, one for position, one for normal
perVertexData[offset++] = obj.objectId;
}
}
}
// upload the per vertex data
gl.bindBuffer(gl.ARRAY_BUFFER, bufferInfo.attribs.perVertexData.buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, perVertexData);
// upload the texture data
gl.bindTexture(gl.TEXTURE_2D, perObjectDataTexture);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, perObjectDataTextureWidth, numObjects,
gl.RGBA, gl.FLOAT, perObjectData);
gl.useProgram(programInfo.program);
// calls gl.bindBuffer, gl.enableVertexAttribArray, gl.vertexAttribPointer
twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo);
// calls gl.activeTexture, gl.bindTexture, gl.uniformXXX
twgl.setUniforms(programInfo, {
lightDirection: v3.normalize([1, 2, 3]),
perObjectDataTexture,
perObjectDataTextureHeight: numObjects,
vertexDataTexture: vertexDataTextureInfo.texture,
vertexDataTextureSize: vertexDataTextureInfo.size,
projection,
view,
});
// calls gl.drawArrays or gl.drawElements
twgl.drawBufferInfo(gl, bufferInfo);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
body { margin: 0; }
canvas { width: 100vw; height: 100vh; display: block; }
<script src="https://twgljs.org/dist/4.x/twgl-full.min.js"></script>
<canvas></canvas>