q5js / q5.js

A sequel to p5.js that's smaller, faster, and optimized for interactive art!
https://q5js.org
GNU Lesser General Public License v3.0
99 stars 8 forks source link

q5-webgpu: Instanced rendering #52

Open quinton-ashley opened 2 months ago

quinton-ashley commented 2 months ago

There's an opportunity with q5's upcoming WebGPU renderer to implement friendly p5 style wrappers for batch rendering, or do it behind the scenes.

Batch rendering could draw many shapes with the same fill and stroke colors or shader in the same render pass. The goal would be to take advantage of the GPUs strength at drawing in parallel, which is faster.

Proposed use example:

fill("red");

batchStart();

for (let i = 0; i < 100; i++) {
  rect(i*10, random(height), 10, 10);
}

batchEnd();

In between the start and end of a batch, drawing functions like rect would add vertices to a vertex buffer behind the scenes.

I hope this will give users an easy and performant way to render millions of 2D shapes.

quinton-ashley commented 1 month ago

Here's ChatGPT's explanation of instanced drawing:

Instanced drawing in WebGPU allows you to draw multiple instances of the same geometry with a single draw call, which can significantly improve performance when rendering many objects. Here's a step-by-step explanation of how it works:

Setup Buffers: Create vertex and instance buffers. The vertex buffer contains the geometry data, while the instance buffer contains per-instance data (e.g., positions, colors).

Create Bind Group Layout: Define the layout of the bind groups, which describe how the shaders will access resources like buffers and textures.

Create Pipeline: Create a render pipeline that includes the vertex and fragment shaders. The vertex shader will use both the vertex and instance buffers.

Encode Commands: Encode the commands to draw the instances. This includes setting the pipeline, binding the resources, and issuing the draw call.

Submit Commands: Submit the encoded commands to the GPU for execution.

Here's an example in JavaScript using WebGPU:

// Assuming you have a WebGPU context (device, queue, etc.)

// Vertex data for a simple triangle
const vertexData = new Float32Array([
    0.0,  0.5,  // Vertex 1
   -0.5, -0.5,  // Vertex 2
    0.5, -0.5   // Vertex 3
]);

// Instance data (e.g., positions for each instance)
const instanceData = new Float32Array([
    -0.5,  0.5,  // Instance 1 position
     0.5,  0.5,  // Instance 2 position
    -0.5, -0.5,  // Instance 3 position
     0.5, -0.5   // Instance 4 position
]);

// Create buffers
const vertexBuffer = device.createBuffer({
    size: vertexData.byteLength,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true
});
new Float32Array(vertexBuffer.getMappedRange()).set(vertexData);
vertexBuffer.unmap();

const instanceBuffer = device.createBuffer({
    size: instanceData.byteLength,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true
});
new Float32Array(instanceBuffer.getMappedRange()).set(instanceData);
instanceBuffer.unmap();

// Create bind group layout and pipeline
const bindGroupLayout = device.createBindGroupLayout({
    entries: []
});

const pipeline = device.createRenderPipeline({
    vertex: {
        module: device.createShaderModule({
            code: `
            @stage(vertex)
            fn main(@location(0) position: vec2<f32>, @location(1) instancePos: vec2<f32>) -> @builtin(position) vec4<f32> {
                return vec4<f32>(position + instancePos, 0.0, 1.0);
            }
            `
        }),
        entryPoint: 'main',
        buffers: [
            {
                arrayStride: 2 * 4,
                attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
            },
            {
                arrayStride: 2 * 4,
                stepMode: 'instance',
                attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x2' }]
            }
        ]
    },
    fragment: {
        module: device.createShaderModule({
            code: `
            @stage(fragment)
            fn main() -> @location(0) vec4<f32> {
                return vec4<f32>(1.0, 1.0, 1.0, 1.0);
            }
            `
        }),
        entryPoint: 'main',
        targets: [{ format: 'bgra8unorm' }]
    },
    primitive: {
        topology: 'triangle-list'
    },
    layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] })
});

// Encode commands
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
    colorAttachments: [{
        view: context.getCurrentTexture().createView(),
        loadValue: { r: 0, g: 0, b: 0, a: 1 },
        storeOp: 'store'
    }]
});

passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, vertexBuffer);
passEncoder.setVertexBuffer(1, instanceBuffer);
passEncoder.draw(3, 4); // 3 vertices, 4 instances
passEncoder.endPass();

// Submit commands
device.queue.submit([commandEncoder.finish()]);

In this example:

The vertex shader takes both vertex positions and instance positions as inputs. The instance buffer is set with stepMode: 'instance' to indicate that it contains per-instance data. The draw call specifies the number of vertices per instance and the number of instances to draw.