diff --git a/sample/cursorControl/index.html b/sample/cursorControl/index.html
new file mode 100644
index 00000000..c41132bb
--- /dev/null
+++ b/sample/cursorControl/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ webgpu-samples: cursor control
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/cursorControl/main.ts b/sample/cursorControl/main.ts
new file mode 100644
index 00000000..f04dd428
--- /dev/null
+++ b/sample/cursorControl/main.ts
@@ -0,0 +1,477 @@
+import { mat4, vec3 } from 'wgpu-matrix';
+import simpleLightingWGSL from './simple-lighting.wgsl';
+import {
+ createPlaneVertices,
+ createSphereVertices,
+ createTorusVertices,
+ reorientInPlace,
+ VertexData,
+} from '../../meshes/primitives';
+import { quitIfWebGPUNotAvailable } from '../util';
+
+const adapter = await navigator.gpu?.requestAdapter({
+ featureLevel: 'compatibility',
+});
+const device = await adapter?.requestDevice();
+quitIfWebGPUNotAvailable(adapter, device);
+
+// Get a WebGPU context from the canvas and configure it
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = canvas.getContext('webgpu');
+const devicePixelRatio = window.devicePixelRatio;
+canvas.width = canvas.clientWidth * devicePixelRatio;
+canvas.height = canvas.clientHeight * devicePixelRatio;
+const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
+context.configure({
+ device,
+ format: presentationFormat,
+});
+
+let mousePos = { x: 0, y: 0 };
+canvas.addEventListener('mousemove', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
+ mousePos = { x, y };
+});
+
+// Creates a buffer and puts data in it.
+function createBufferWithData(
+ device: GPUDevice,
+ data: ArrayBufferView,
+ usage: GPUBufferUsageFlags
+) {
+ const buffer = device.createBuffer({
+ size: data.byteLength,
+ usage: usage | GPUBufferUsage.COPY_DST,
+ });
+ device.queue.writeBuffer(buffer, 0, data);
+ return buffer;
+}
+
+// Represents Geometry like a plane, a sphere, a torus
+type Geometry = {
+ vertexBuffer: GPUBuffer;
+ indexBuffer: GPUBuffer;
+ indexFormat: GPUIndexFormat;
+ numVertices: number;
+};
+
+// Creates vertex and index buffers for the given data.
+function createGeometry(
+ device: GPUDevice,
+ { vertices, indices }: VertexData
+): Geometry {
+ const vertexBuffer = createBufferWithData(
+ device,
+ vertices,
+ GPUBufferUsage.VERTEX
+ );
+ const indexBuffer = createBufferWithData(
+ device,
+ indices,
+ GPUBufferUsage.INDEX
+ );
+ return {
+ vertexBuffer,
+ indexBuffer,
+ indexFormat: 'uint16',
+ numVertices: indices.length,
+ };
+}
+
+// Create Geometry for our scenes.
+const planeVerts = reorientInPlace(
+ createPlaneVertices(),
+ mat4.multiply(mat4.rotationY(Math.PI), mat4.rotationX(-Math.PI / 2))
+);
+const planeGeo = createGeometry(device, planeVerts);
+const sphereGeo = createGeometry(device, createSphereVertices());
+const torusGeo = createGeometry(
+ device,
+ createTorusVertices({ radius: 0.7, thickness: 0.3 })
+);
+
+// Create a bind group layout and pipeline layout so we can
+// share the bind groups with multiple pipelines.
+const bindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+ buffer: {},
+ },
+ {
+ binding: 1,
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+ buffer: {},
+ },
+ ],
+});
+const layout = device.createPipelineLayout({
+ bindGroupLayouts: [bindGroupLayout],
+});
+
+const module = device.createShaderModule({ code: simpleLightingWGSL });
+const pipelineDesc: GPURenderPipelineDescriptor = {
+ layout,
+ vertex: {
+ module,
+ buffers: [
+ {
+ arrayStride: 32,
+ attributes: [
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
+ { shaderLocation: 2, offset: 24, format: 'float32x2' },
+ ],
+ },
+ ],
+ },
+ fragment: {
+ module,
+ targets: [{ format: presentationFormat }],
+ },
+ primitive: {
+ topology: 'triangle-list',
+ cullMode: 'back',
+ },
+ depthStencil: {
+ depthWriteEnabled: true,
+ depthCompare: 'less',
+ // The stencilFront setting specifies what happens when a front facing
+ // triangle is rasterized. passOp: 'replace' means, when the texel "pass"es
+ // the depth and stencil tests, replace the value in the stencil texture
+ // with the reference value. The depth test is specified above. The stencil
+ // test defaults to "always". The reference value is specified in the
+ // command buffer with setStencilReference.
+ // Effectively we'll draw the reference value into the stencil texture
+ // with the reference value anywhere we draw the plane controlled by a cursor.
+ stencilFront: { passOp: 'replace' },
+ format: 'depth24plus-stencil8',
+ },
+};
+
+// Make two render pipelines. One to set the stencil and one to draw
+// only where the stencil equals the stencil reference value.
+const stencilSetPipeline = device.createRenderPipeline(pipelineDesc);
+// passOp: 'keep' means, when the texel "pass"es the depth and stencil tests,
+// keep the value in the stencil texture as is. We set the stencil
+// test to 'equal' so the texel will only pass the stencil test when
+// the reference value, set in the command buffer with setStencilReference,
+// matches what's already in the stencil texture.
+pipelineDesc.depthStencil.stencilFront.passOp = 'keep';
+pipelineDesc.depthStencil.stencilFront.compare = 'equal';
+const stencilMaskPipeline = device.createRenderPipeline(pipelineDesc);
+
+function r(min: number, max?: number) {
+ if (typeof max === 'undefined') {
+ max = min;
+ min = 0;
+ }
+ return Math.random() * (max - min) + min;
+}
+
+const hsl = (h: number, s: number, l: number) =>
+ `hsl(${(h * 360) | 0}, ${s * 100}%, ${(l * 100) | 0}%)`;
+
+const cssColorToRGBA8 = (() => {
+ const canvas = new OffscreenCanvas(1, 1);
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ return (cssColor: string) => {
+ ctx.clearRect(0, 0, 1, 1);
+ ctx.fillStyle = cssColor;
+ ctx.fillRect(0, 0, 1, 1);
+ return Array.from(ctx.getImageData(0, 0, 1, 1).data);
+ };
+})();
+
+const cssColorToRGBA = (cssColor: string) =>
+ cssColorToRGBA8(cssColor).map((v: number) => v / 255);
+const hslToRGBA = (h: number, s: number, l: number) =>
+ cssColorToRGBA(hsl(h, s, l));
+
+const randElem = (arr: T[]) => arr[r(arr.length) | 0];
+
+// Per object data.
+type ObjectInfo = {
+ uniformValues: Float32Array;
+ uniformBuffer: GPUBuffer;
+ worldMatrix: Float32Array;
+ bindGroup: GPUBindGroup;
+ geometry: Geometry;
+};
+
+// Per scene data.
+type Scene = {
+ objectInfos: ObjectInfo[];
+ sharedUniformBuffer: GPUBuffer;
+ sharedUniformValues: Float32Array;
+ viewProjectionMatrix: Float32Array;
+ lightDirection: Float32Array;
+};
+
+/**
+ * Make a scene with a bunch of semi-randomly colored objects/
+ * Each scene has a shared uniform buffer for viewProjection and lightDirection
+ * Each object has it's own uniform buffer for its color and its worldMatrix.
+ */
+function makeScene(
+ numInstances: number,
+ hue: number,
+ geometries: Geometry[]
+): Scene {
+ const sharedUniformValues = new Float32Array(16 + 4); // mat4x4f, vec3f
+ const viewProjectionMatrix = sharedUniformValues.subarray(0, 16);
+ const lightDirection = sharedUniformValues.subarray(16, 19);
+ const sharedUniformBuffer = device.createBuffer({
+ size: sharedUniformValues.byteLength,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ const objectInfos: ObjectInfo[] = [];
+ for (let i = 0; i < numInstances; ++i) {
+ const uniformValues = new Float32Array(16 + 4); // mat4x4f, vec4f
+ const worldMatrix = uniformValues.subarray(0, 16);
+ const colorValue = uniformValues.subarray(16, 20);
+ const uniformBuffer = device.createBuffer({
+ size: uniformValues.byteLength,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+ colorValue.set(hslToRGBA(hue + r(0.2), r(0.7, 1), r(0.5, 0.8)));
+ device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
+
+ const bindGroup = device.createBindGroup({
+ layout: bindGroupLayout,
+ entries: [
+ { binding: 0, resource: { buffer: uniformBuffer } },
+ { binding: 1, resource: { buffer: sharedUniformBuffer } },
+ ],
+ });
+
+ objectInfos.push({
+ uniformValues,
+ uniformBuffer,
+ worldMatrix,
+ bindGroup,
+ geometry: randElem(geometries),
+ });
+ }
+ return {
+ objectInfos,
+ sharedUniformBuffer,
+ sharedUniformValues,
+ viewProjectionMatrix,
+ lightDirection,
+ };
+}
+
+// Make our masking scenes, each with a single plane.
+const maskScene = makeScene(1, 0.5, [planeGeo]);
+
+// Make our object scenes, one for the background and one for the plane.
+const sceneBg = makeScene(100, 0.5, [sphereGeo]);
+const sceneMask = makeScene(100, 0.1, [torusGeo]);
+
+let depthTexture: GPUTexture;
+let canvasTexture: GPUTexture;
+
+/**
+ * Update the viewProject and light position of the scene
+ * and world matrix of the plane
+ */
+function updateMask(
+ time: number,
+ {
+ objectInfos,
+ sharedUniformBuffer,
+ sharedUniformValues,
+ viewProjectionMatrix,
+ lightDirection,
+ }: Scene
+) {
+ const projection = mat4.perspective(
+ (30 * Math.PI) / 180,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.5,
+ 100
+ );
+ const eye = [0, 0, 45];
+ const target = [0, 0, 0];
+ const up = [0, 1, 0];
+
+ const view = mat4.lookAt(eye, target, up);
+ mat4.multiply(projection, view, viewProjectionMatrix);
+
+ lightDirection.set(vec3.normalize([1, 8, 10]));
+
+ device.queue.writeBuffer(sharedUniformBuffer, 0, sharedUniformValues);
+
+ objectInfos.forEach(({ uniformBuffer, uniformValues, worldMatrix }) => {
+ mat4.identity(worldMatrix);
+ const worldX = mousePos.x * 10;
+ const worldY = mousePos.y * 10;
+ mat4.translate(worldMatrix, [worldX, worldY, 0], worldMatrix);
+ mat4.scale(worldMatrix, [10, 10, 10], worldMatrix);
+ device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
+ });
+}
+
+/**
+ * Update the viewProjection and light position.
+ * and world matrix of the every object in the scene.
+ * This update scene has a fixed position camera and
+ * has objects orbiting and spinning around the origin.
+ */
+function updateScene(
+ time: number,
+ {
+ objectInfos,
+ sharedUniformBuffer,
+ sharedUniformValues,
+ viewProjectionMatrix,
+ lightDirection,
+ }: Scene
+) {
+ const projection = mat4.perspective(
+ (30 * Math.PI) / 180,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.5,
+ 100
+ );
+ const eye = [0, 0, 35];
+ const target = [0, 0, 0];
+ const up = [0, 1, 0];
+
+ const view = mat4.lookAt(eye, target, up);
+ mat4.multiply(projection, view, viewProjectionMatrix);
+
+ lightDirection.set(vec3.normalize([1, 8, 10]));
+
+ device.queue.writeBuffer(sharedUniformBuffer, 0, sharedUniformValues);
+
+ objectInfos.forEach(({ uniformBuffer, uniformValues, worldMatrix }, i) => {
+ mat4.identity(worldMatrix);
+ mat4.translate(
+ worldMatrix,
+ [0, 0, Math.sin(i * 3.721 + time * 0.1) * 10],
+ worldMatrix
+ );
+ mat4.rotateX(worldMatrix, i * 4.567, worldMatrix);
+ mat4.rotateY(worldMatrix, i * 2.967, worldMatrix);
+ mat4.translate(
+ worldMatrix,
+ [0, 0, Math.sin(i * 9.721 + time * 0.1) * 10],
+ worldMatrix
+ );
+ mat4.rotateX(worldMatrix, time * 0.53 + i, worldMatrix);
+ device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
+ });
+}
+
+/**
+ * Draw a scene and every object in it with a specific stencilReference value
+ */
+function drawScene(
+ encoder: GPUCommandEncoder,
+ renderPassDescriptor: GPURenderPassDescriptor,
+ pipeline: GPURenderPipeline,
+ scene: Scene,
+ stencilRef: number
+) {
+ const { objectInfos } = scene;
+
+ renderPassDescriptor.colorAttachments[0].view = canvasTexture.createView();
+ renderPassDescriptor.depthStencilAttachment.view = depthTexture.createView();
+
+ const pass = encoder.beginRenderPass(renderPassDescriptor);
+ pass.setPipeline(pipeline);
+ pass.setStencilReference(stencilRef);
+
+ objectInfos.forEach(({ bindGroup, geometry }) => {
+ pass.setBindGroup(0, bindGroup);
+ pass.setVertexBuffer(0, geometry.vertexBuffer);
+ pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat);
+ pass.drawIndexed(geometry.numVertices);
+ });
+
+ pass.end();
+}
+
+const clearPassDesc: GPURenderPassDescriptor = {
+ colorAttachments: [
+ {
+ view: undefined, // Assigned later
+ clearValue: [0.2, 0.2, 0.2, 1.0],
+ loadOp: 'clear',
+ storeOp: 'store',
+ },
+ ],
+ depthStencilAttachment: {
+ view: undefined, // Assigned later
+ depthClearValue: 1,
+ depthLoadOp: 'clear',
+ depthStoreOp: 'store',
+ stencilLoadOp: 'clear',
+ stencilStoreOp: 'store',
+ },
+};
+
+const loadPassDesc: GPURenderPassDescriptor = {
+ colorAttachments: [
+ {
+ view: undefined, // Assigned later
+ loadOp: 'load',
+ storeOp: 'store',
+ },
+ ],
+ depthStencilAttachment: {
+ view: undefined, // Assigned later
+ depthClearValue: 1,
+ depthLoadOp: 'clear',
+ depthStoreOp: 'store',
+ stencilLoadOp: 'load',
+ stencilStoreOp: 'store',
+ },
+};
+
+function render(time: number) {
+ time *= 0.001;
+
+ canvasTexture = context.getCurrentTexture();
+ // If we don't have a depth texture OR if its size is different
+ // from the canvasTexture when make a new depth texture
+ if (
+ !depthTexture ||
+ depthTexture.width !== canvasTexture.width ||
+ depthTexture.height !== canvasTexture.height
+ ) {
+ depthTexture?.destroy();
+ depthTexture = device.createTexture({
+ size: [canvasTexture.width, canvasTexture.height],
+ format: 'depth24plus-stencil8',
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+ }
+
+ updateMask(time, maskScene);
+
+ updateScene(time, sceneBg);
+ updateScene(time, sceneMask);
+
+ const encoder = device.createCommandEncoder();
+
+ // Draw the plane of the stencil buffer
+ // each with a different stencil value.
+ drawScene(encoder, clearPassDesc, stencilSetPipeline, maskScene, 1);
+
+ // Draw each scene of moving objects but only where the stencil value
+ // matches the stencil reference.
+ drawScene(encoder, loadPassDesc, stencilMaskPipeline, sceneBg, 0);
+ drawScene(encoder, loadPassDesc, stencilMaskPipeline, sceneMask, 1);
+
+ device.queue.submit([encoder.finish()]);
+
+ requestAnimationFrame(render);
+}
+requestAnimationFrame(render);
diff --git a/sample/cursorControl/meta.ts b/sample/cursorControl/meta.ts
new file mode 100644
index 00000000..e928abfe
--- /dev/null
+++ b/sample/cursorControl/meta.ts
@@ -0,0 +1,14 @@
+export default {
+ name: 'Cursor Control',
+ description: `\
+This example shows interactive stencil masking in WebGPU using a single 2D plane that follows the cursor to create a dynamic cutout.
+A rotating torus is revealed only within this masked area, while a sphere remains visible outside it.
+The effect uses multi-pass rendering and stencil buffer logic to control visibility based on cursor interaction.
+`,
+ filename: __DIRNAME__,
+ sources: [
+ { path: 'main.ts' },
+ { path: '../../meshes/primitives.ts' },
+ { path: 'simple-lighting.wgsl' },
+ ],
+};
diff --git a/sample/cursorControl/simple-lighting.wgsl b/sample/cursorControl/simple-lighting.wgsl
new file mode 100644
index 00000000..6c535492
--- /dev/null
+++ b/sample/cursorControl/simple-lighting.wgsl
@@ -0,0 +1,41 @@
+struct Uniforms {
+ world: mat4x4f,
+ color: vec4f,
+};
+
+struct SharedUniforms {
+ viewProjection: mat4x4f,
+ lightDirection: vec3f,
+};
+
+@group(0) @binding(0) var uni: Uniforms;
+@group(0) @binding(1) var sharedUni: SharedUniforms;
+
+struct MyVSInput {
+ @location(0) position: vec4f,
+ @location(1) normal: vec3f,
+ @location(2) texcoord: vec2f,
+};
+
+struct MyVSOutput {
+ @builtin(position) position: vec4f,
+ @location(0) normal: vec3f,
+ @location(1) texcoord: vec2f,
+};
+
+@vertex
+fn myVSMain(v: MyVSInput) -> MyVSOutput {
+ var vsOut: MyVSOutput;
+ vsOut.position = sharedUni.viewProjection * uni.world * v.position;
+ vsOut.normal = (uni.world * vec4f(v.normal, 0.0)).xyz;
+ vsOut.texcoord = v.texcoord;
+ return vsOut;
+}
+
+@fragment
+fn myFSMain(v: MyVSOutput) -> @location(0) vec4f {
+ let diffuseColor = uni.color;
+ let a_normal = normalize(v.normal);
+ let l = dot(a_normal, sharedUni.lightDirection) * 0.5 + 0.5;
+ return vec4f(diffuseColor.rgb * l, diffuseColor.a);
+}
\ No newline at end of file
diff --git a/src/samples.ts b/src/samples.ts
index 8a9ee1ab..bdf22727 100644
--- a/src/samples.ts
+++ b/src/samples.ts
@@ -9,6 +9,7 @@ import clusteredShading from '../sample/clusteredShading/meta';
import cornell from '../sample/cornell/meta';
import computeBoids from '../sample/computeBoids/meta';
import cubemap from '../sample/cubemap/meta';
+import cursorControl from '../sample/cursorControl/meta';
import deferredRendering from '../sample/deferredRendering/meta';
import fractalCube from '../sample/fractalCube/meta';
import gameOfLife from '../sample/gameOfLife/meta';
@@ -148,6 +149,7 @@ export const pageCategories: PageCategory[] = [
description:
'Demos integrating WebGPU with other functionalities of the web platform.',
samples: {
+ cursorControl,
resizeCanvas,
resizeObserverHDDPI,
transparentCanvas,