Skip to content

Instantly share code, notes, and snippets.

@greggman
Last active January 15, 2026 07:47
Show Gist options
  • Select an option

  • Save greggman/d65308f2cc8aac3bd3b4bcae0832a28a to your computer and use it in GitHub Desktop.

Select an option

Save greggman/d65308f2cc8aac3bd3b4bcae0832a28a to your computer and use it in GitHub Desktop.
WebGPU: Editor Panes
:root {
color-scheme: light dark;
}
html, body {
margin: 0;
height: 100%;
}
.scene-view {
width: 100%;
height: 100%;
display: block;
}
#outer {
display: flex;
height: 100%;
}
#dock {
flex: 1 1 auto;
min-width: 0;
height: 100%;
}
#ui {
border-left: 1px solid gray;
}
#ui.hide-ui {
position: absolute;
top: 0;
right: 0;
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dockview-core@4.13.1/dist/styles/dockview.css">
<div id="outer">
<div id="dock" style=""></div>
<div id="ui"></div>
</div>
import { createDockview } from "https://cdn.jsdelivr.net/npm/dockview-core@4.13.1/dist/dockview-core.esm.js";
import { mat4, vec3 } from 'https://wgpu-matrix.org/dist/2.x/wgpu-matrix.module.js';
import GUI from 'https://webgpufundamentals.org/3rdparty/muigui-0.x.module.js';
import { addButtonLeftJustified } from 'https://webgpufundamentals.org/webgpu/resources/js/gui-helpers.js';
const host = document.querySelector("#dock");
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
const canvasToData = new Map();
function createCubeVertices() {
const positions = [
// left
0, 0, 0,
0, 0, -1,
0, 1, 0,
0, 1, -1,
// right
1, 0, 0,
1, 0, -1,
1, 1, 0,
1, 1, -1,
];
const indices = [
0, 2, 1, 2, 3, 1, // left
4, 5, 6, 6, 5, 7, // right
0, 4, 2, 2, 4, 6, // front
1, 3, 5, 5, 3, 7, // back
0, 1, 4, 4, 1, 5, // bottom
2, 6, 3, 3, 6, 7, // top
];
const quadColors = [
200, 70, 120, // left column front
80, 70, 200, // left column back
70, 200, 210, // top
160, 160, 220, // top rung right
90, 130, 110, // top rung bottom
200, 200, 70, // between top and middle rung
];
const numVertices = indices.length;
const vertexData = new Float32Array(numVertices * 4); // xyz + color
const colorData = new Uint8Array(vertexData.buffer);
for (let i = 0; i < indices.length; ++i) {
const positionNdx = indices[i] * 3;
const position = positions.slice(positionNdx, positionNdx + 3);
vertexData.set(position, i * 4);
const quadNdx = (i / 6 | 0) * 3;
const color = quadColors.slice(quadNdx, quadNdx + 3);
colorData.set(color, i * 16 + 12);
colorData[i * 16 + 15] = 255;
}
return {
vertexData,
numVertices,
aabb: {
min: [ 0, 0, -1],
max: [ 1, 1, 0],
},
};
}
function computeAABBForVertices(vertexData, stride = 3) {
const numVertices = vertexData.length / stride;
const min = [...vertexData.slice(0, 3)];
const max = [...min];
for (let i = 1; i < numVertices; ++i) {
const offset = i * stride;
const p = vertexData.slice(offset, offset + 3);
vec3.min(min, p, min);
vec3.max(max, p, max);
}
return { min, max };
}
function createFVertices() {
const positions = [
// left column
0, 0, 0,
30, 0, 0,
0, 150, 0,
30, 150, 0,
// top rung
30, 0, 0,
100, 0, 0,
30, 30, 0,
100, 30, 0,
// middle rung
30, 60, 0,
70, 60, 0,
30, 90, 0,
70, 90, 0,
// left column back
0, 0, 30,
30, 0, 30,
0, 150, 30,
30, 150, 30,
// top rung back
30, 0, 30,
100, 0, 30,
30, 30, 30,
100, 30, 30,
// middle rung back
30, 60, 30,
70, 60, 30,
30, 90, 30,
70, 90, 30,
];
const indices = [
0, 2, 1, 2, 3, 1, // left column
4, 6, 5, 6, 7, 5, // top run
8, 10, 9, 10, 11, 9, // middle run
12, 13, 14, 14, 13, 15, // left column back
16, 17, 18, 18, 17, 19, // top run back
20, 21, 22, 22, 21, 23, // middle run back
0, 5, 12, 12, 5, 17, // top
5, 7, 17, 17, 7, 19, // top rung right
6, 18, 7, 18, 19, 7, // top rung bottom
6, 8, 18, 18, 8, 20, // between top and middle rung
8, 9, 20, 20, 9, 21, // middle rung top
9, 11, 21, 21, 11, 23, // middle rung right
10, 22, 11, 22, 23, 11, // middle rung bottom
10, 3, 22, 22, 3, 15, // stem right
2, 14, 3, 14, 15, 3, // bottom
0, 12, 2, 12, 14, 2, // left
];
const quadColors = [
200, 70, 120, // left column front
200, 70, 120, // top rung front
200, 70, 120, // middle rung front
80, 70, 200, // left column back
80, 70, 200, // top rung back
80, 70, 200, // middle rung back
70, 100, 210, // top
160, 160, 220, // top rung right
90, 130, 110, // top rung bottom
200, 200, 70, // between top and middle rung
210, 100, 70, // middle rung top
210, 160, 70, // middle rung right
70, 180, 210, // middle rung bottom
100, 70, 210, // stem right
76, 210, 100, // bottom
140, 210, 80, // left
];
const numVertices = indices.length;
const vertexData = new Float32Array(numVertices * 4); // xyz + color
const colorData = new Uint8Array(vertexData.buffer);
for (let i = 0; i < indices.length; ++i) {
const positionNdx = indices[i] * 3;
const position = positions.slice(positionNdx, positionNdx + 3);
vertexData.set(position, i * 4);
const quadNdx = (i / 6 | 0) * 3;
const color = quadColors.slice(quadNdx, quadNdx + 3);
colorData.set(color, i * 16 + 12);
colorData[i * 16 + 15] = 255;
}
return {
vertexData,
numVertices,
aabb: computeAABBForVertices(vertexData, 4),
};
}
const degToRad = d => d * Math.PI / 180;
class SceneGraphNode {
constructor(name, source) {
this.name = name;
this.children = [];
this.localMatrix = mat4.identity();
this.worldMatrix = mat4.identity();
this.source = source;
}
addChild(child) {
child.setParent(this);
}
removeChild(child) {
child.setParent(null);
}
setParent(parent) {
// remove us from our parent
if (this.parent) {
const ndx = this.parent.children.indexOf(this);
if (ndx >= 0) {
this.parent.children.splice(ndx, 1);
}
}
// Add us to our new parent
if (parent) {
parent.children.push(this);
}
this.parent = parent;
}
updateWorldMatrix() {
// update the local matrix from its source if it has one.
this.source?.getMatrix(this.localMatrix);
if (this.parent) {
// we have a parent do the math
mat4.multiply(this.parent.worldMatrix, this.localMatrix, this.worldMatrix);
} else {
// we have no parent so just copy local to world
mat4.copy(this.localMatrix, this.worldMatrix);
}
// now process all the children
this.children.forEach(function(child) {
child.updateWorldMatrix();
});
}
}
class TRS {
constructor({
translation = [0, 0, 0],
rotation = [0, 0, 0],
scale = [1, 1, 1],
} = {}) {
this.translation = new Float32Array(translation);
this.rotation = new Float32Array(rotation);
this.scale = new Float32Array(scale);
}
getMatrix(dst) {
mat4.translation(this.translation, dst);
mat4.rotateX(dst, this.rotation[0], dst);
mat4.rotateY(dst, this.rotation[1], dst);
mat4.rotateZ(dst, this.rotation[2], dst);
mat4.scale(dst, this.scale, dst);
return dst;
}
}
const module = device.createShaderModule({
code: /* wgsl */ `
struct Uniforms {
matrix: mat4x4f,
color: vec4f,
id: u32,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) var<uniform> uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.matrix * vert.position;
vsOut.color = vert.color;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vsOut.color * uni.color;
}
@fragment fn fsPicking(vsOut: VSOutput) -> @location(0) vec4u {
return vec4u(uni.id);
}
`,
});
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { minBindingSize: 96 },
},
],
});
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout],
});
const pipeline = device.createRenderPipeline({
label: '2 attributes with color',
layout: pipelineLayout,
vertex: {
module,
buffers: [
{
arrayStride: (4) * 4, // (3) floats 4 bytes each + one 4 byte color
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x3'}, // position
{shaderLocation: 1, offset: 12, format: 'unorm8x4'}, // color
],
},
],
},
fragment: {
module,
entryPoint: 'fs',
targets: [{ format: presentationFormat }],
},
primitive: {
cullMode: 'back',
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
},
});
const pickPipeline = device.createRenderPipeline({
label: '2 attributes with id for picking',
layout: pipelineLayout,
vertex: {
module,
buffers: [
{
arrayStride: (4) * 4, // (3) floats 4 bytes each + one 4 byte color
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x3'}, // position
{shaderLocation: 1, offset: 12, format: 'unorm8x4'}, // color
],
},
],
},
fragment: {
module,
entryPoint: 'fsPicking',
targets: [{ format: 'r32uint' }],
},
primitive: {
cullMode: 'back',
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
},
});
const postProcessModule = device.createShaderModule({
code: /* wgsl */ `
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) texcoord: vec2f,
};
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32,
) -> VSOutput {
var pos = array(
vec2f(-1.0, -1.0),
vec2f(-1.0, 3.0),
vec2f( 3.0, -1.0),
);
var vsOutput: VSOutput;
let xy = pos[vertexIndex];
vsOutput.position = vec4f(xy, 0.0, 1.0);
vsOutput.texcoord = xy * vec2f(0.5, -0.5) + vec2f(0.5);
return vsOutput;
}
@group(0) @binding(0) var mask: texture_2d<f32>;
fn isOnEdge(pos: vec2i) -> bool {
// Note: we need to make sure we don't use out of bounds
// texel coordinates with textureLoad as that returns
// different results on different GPUs
let size = vec2i(textureDimensions(mask, 0));
let start = max(pos - 2, vec2i(0));
let end = min(pos + 2, size);
for (var y = start.y; y <= end.y; y++) {
for (var x = start.x; x <= end.x; x++) {
let s = textureLoad(mask, vec2i(x, y), 0).a;
if (s > 0) {
return true;
}
}
}
return false;
};
@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
let pos = vec2i(fsInput.position.xy);
// get the current. If it's not 0 we're inside the selected objects
let s = textureLoad(mask, pos, 0).a;
if (s > 0) {
discard;
}
let hit = isOnEdge(pos);
if (!hit) {
discard;
}
return vec4f(1, 0.5, 0, 1);
}
`,
});
const postProcessPipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module: postProcessModule },
fragment: {
module: postProcessModule,
targets: [ { format: presentationFormat }],
},
});
const postProcessRenderPassDescriptor = {
label: 'post process render pass',
colorAttachments: [
{ loadOp: 'load', storeOp: 'store' },
],
};
function addTRSSceneGraphNode(
name,
parent,
trs,
) {
const node = new SceneGraphNode(name, new TRS(trs));
if (parent) {
node.setParent(parent);
}
return node;
}
function addCubeNode(name, parent, trs, color) {
const node = addTRSSceneGraphNode(name, parent, trs);
return addMesh(node, cubeVertices, color);
}
const objectInfos = [];
function createObjectInfo() {
// matrix, color, id, padding
const uniformBufferSize = (16 + 4 + 1 + 3) * 4;
const uniformBuffer = device.createBuffer({
label: 'uniforms',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformValues = new Float32Array(uniformBufferSize / 4);
const asU32 = new Uint32Array(uniformValues.buffer);
// offsets to the various uniform values in float32 indices
const kMatrixOffset = 0;
const kColorOffset = 16;
const kIdOffset = 20;
const matrixValue = uniformValues.subarray(kMatrixOffset, kMatrixOffset + 16);
const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
const idValue = asU32.subarray(kIdOffset, kIdOffset + 1);
const bindGroup = device.createBindGroup({
label: 'bind group for object',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: uniformBuffer }},
],
});
return {
uniformBuffer,
uniformValues,
colorValue,
matrixValue,
idValue,
bindGroup,
};
}
const meshes = [];
function addMesh(node, vertices, color) {
const mesh = {
node,
vertices,
color,
};
meshes.push(mesh);
return mesh;
}
function createVertices({vertexData, numVertices, aabb}, name) {
const vertexBuffer = device.createBuffer({
label: `${name}: vertex buffer vertices`,
size: vertexData.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
return {
vertexBuffer,
numVertices,
aabb,
vertexData,
};
}
const cubeVertices = createVertices(createCubeVertices(), 'cube');
const kHandleColor = [0.5, 0.5, 0.5, 1];
const kDrawerColor = [1, 1, 1, 1];
const kCabinetColor = [0.75, 0.75, 0.75, 0.75];
const kNumDrawersPerCabinet = 4;
const kNumCabinets = 5;
const kDrawerSize = [40, 30, 50];
const kHandleSize = [10, 2, 2];
const [kWidth, kHeight, kDepth] = [0, 1, 2];
const kHandlePosition = [
(kDrawerSize[kWidth] - kHandleSize[kWidth]) / 2,
kDrawerSize[kHeight] * 2 / 3,
kHandleSize[kDepth],
];
const kDrawerSpacing = kDrawerSize[kHeight] + 3;
const kCabinetSpacing = kDrawerSize[kWidth] + 10;
function addDrawer(parent, drawerNdx) {
const drawerName = `drawer${drawerNdx}`;
// add a node for the entire drawer
const drawer = addTRSSceneGraphNode(
drawerName, parent, {
translation: [3, drawerNdx * kDrawerSpacing + 5, 1],
});
// add a node with a cube for the drawer cube.
addCubeNode(`${drawerName}-drawer-mesh`, drawer, {
scale: kDrawerSize,
}, kDrawerColor);
// add a node with a cube for the handle
addCubeNode(`${drawerName}-handle-mesh`, drawer, {
translation: kHandlePosition,
scale: kHandleSize,
}, kHandleColor);
}
function addCabinet(parent, cabinetNdx) {
const cabinetName = `cabinet${cabinetNdx}`;
// add a node for the entire cabinet
const cabinet = addTRSSceneGraphNode(
cabinetName, parent, {
translation: [cabinetNdx * kCabinetSpacing, 0, 0],
});
// add a node with a cube for the cabinet
const kCabinetSize = [
kDrawerSize[kWidth] + 6,
kDrawerSpacing * kNumDrawersPerCabinet + 6,
kDrawerSize[kDepth] + 4,
];
addCubeNode(
`${cabinetName}-mesh`, cabinet, {
scale: kCabinetSize,
}, kCabinetColor);
// Add the drawers
for (let drawerNdx = 0; drawerNdx < kNumDrawersPerCabinet; ++drawerNdx) {
addDrawer(cabinet, drawerNdx);
}
}
const nodeToUISettings = new Map();
const root = new SceneGraphNode('root');
class OrbitCamera {
#camTarget;
#camPan;
#camTilt;
#camExtend;
#cam;
constructor() {
// Create Camera Rig
this.#camTarget = addTRSSceneGraphNode('cam-target');
this.#camPan = addTRSSceneGraphNode('cam-pan', this.#camTarget);
this.#camTilt = addTRSSceneGraphNode('cam-tilt', this.#camPan);
this.#camExtend = addTRSSceneGraphNode('cam-extend', this.#camTilt);
this.#cam = addTRSSceneGraphNode('cam', this.#camExtend);
nodeToUISettings.set(this.#camTarget, { trs: [0, 1, 2] });
nodeToUISettings.set(this.#camPan, { trs: [4] });
nodeToUISettings.set(this.#camTilt, { trs: [3] });
nodeToUISettings.set(this.#camExtend, { trs: [2] });
nodeToUISettings.set(this.#cam, { trs: [] });
}
setParent(parent) {
this.#camTarget.setParent(parent);
}
getCameraMatrix() {
return this.#cam.worldMatrix;
}
updateWorldMatrix() {
return this.#camTarget.updateWorldMatrix();
}
getUpdateHelper() {
const startTilt = this.tilt;
const startPan = this.pan;
const startRadius = this.radius;
const startCameraMatrix = mat4.copy(this.getCameraMatrix());
const startTarget = vec3.copy(this.target);
return {
panAndTilt: (deltaPan, deltaTilt) => {
this.tilt = startTilt - deltaTilt;
this.pan = startPan - deltaPan;
},
track: (deltaX, deltaY) => {
const worldDirection = vec3.transformMat3([deltaX, deltaY, 0], startCameraMatrix);
const inv = mat4.inverse(this.#camTarget.parent?.worldMatrix ?? mat4.identity());
const cameraDirection = vec3.transformMat3(worldDirection, inv);
vec3.add(startTarget, cameraDirection, this.#camTarget.source.translation);
},
dolly: (delta) => {
this.radius = startRadius + delta;
},
};
}
get pan() { return this.#camPan.source.rotation[1]; }
set pan(v) { this.#camPan.source.rotation[1] = v; }
get tilt() { return this.#camTilt.source.rotation[0]; }
set tilt(v) { this.#camTilt.source.rotation[0] = v; }
get radius() { return this.#camExtend.source.translation[2]; }
set radius(v) { this.#camExtend.source.translation[2] = v; }
get target() { return vec3.copy(this.#camTarget.source.translation); }
setTarget(worldPosition) {
const inv = mat4.inverse(this.#camTarget.parent?.worldMatrix ?? mat4.identity());
vec3.transformMat4(worldPosition, inv, this.#camTarget.source.translation);
}
}
function addPerCanvasInfo(canvas, panelId) {
let postProcessBindGroup;
let lastPostProcessTexture;
function setupPostProcess(texture) {
if (!postProcessBindGroup || texture !== lastPostProcessTexture) {
lastPostProcessTexture = texture;
postProcessBindGroup = device.createBindGroup({
layout: postProcessPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: texture.createView() },
],
});
}
}
function postProcess(encoder, srcTexture, dstTexture) {
postProcessRenderPassDescriptor.colorAttachments[0].view = dstTexture.createView();
const pass = encoder.beginRenderPass(postProcessRenderPassDescriptor);
pass.setPipeline(postProcessPipeline);
pass.setBindGroup(0, postProcessBindGroup);
pass.draw(3);
pass.end();
}
const orbitCamera = new OrbitCamera();
orbitCamera.setTarget([120, 80, 0]);
orbitCamera.tilt = Math.PI * -0.2;
orbitCamera.radius = 300;
let lastPickX;
let lastPickY;
let pickableMeshes;
async function pickMeshes(e, cam) {
// if we have no meshes OR the pointer moved
if (!pickableMeshes ||
lastPickX !== e.clientX ||
lastPickY !== e.clientY) {
lastPickX = e.clientX;
lastPickY = e.clientY;
// get all the meshes.
pickableMeshes = meshes.slice();
}
const rect = e.target.getBoundingClientRect()
const clipX = (e.clientX - rect.left) / e.target.clientWidth * 2 - 1;
const clipY = (e.clientY - rect.top ) / e.target.clientHeight * -2 + 1;
const viewProjectionMatrix = getViewProjectionMatrix(cam, canvas);
// pick from the available meshes
let id = await pick(clipX, clipY, viewProjectionMatrix, pickableMeshes);
if (id === 0) {
// if we didn't find one, try all of them again
pickableMeshes = meshes.slice();
id = await pick(clipX, clipY, viewProjectionMatrix, pickableMeshes);
// If we still didn't find one there was nothing under the pointer
if (id === 0) {
setCurrentSceneGraphNode(undefined);
return;
}
}
// remove the picked mesh and get its node
let node = pickableMeshes.splice(id - 1, 1)[0].node;
if (!settings.showMeshNodes) {
while (node.name.includes('mesh')) {
node = node.parent;
}
}
setCurrentSceneGraphNode(node);
}
function addOrbitCameraEventListeners(cam, elem) {
let startX;
let startY;
let moved;
let lastMode;
let camHelper;
let doubleTapMode;
let lastSingleTapTime;
let startPinchDistance;
const pointerToLastPosition = new Map();
const computePinchDistance = () => {
const pos = [...pointerToLastPosition.values()];
const dx = pos[0].x - pos[1].x;
const dy = pos[0].y - pos[1].y;
return Math.hypot(dx, dy);
};
const updateStartPosition = (e) => {
startX = e.clientX;
startY = e.clientY;
if (pointerToLastPosition.size === 2) {
startPinchDistance = computePinchDistance();
}
camHelper = cam.getUpdateHelper();
};
const onMove = (e) => {
if (!pointerToLastPosition.has(e.pointerId) ||
!canvas.hasPointerCapture(e.pointerId)) {
return;
}
pointerToLastPosition.set(e.pointerId, { x: e.clientX, y: e.clientY });
const mode = pointerToLastPosition.size === 2
? 'pinch'
: pointerToLastPosition.size > 2
? 'undefined'
: doubleTapMode
? 'doubleTapZoom'
: e.shiftKey || (e.buttons & 4) !== 0
? 'track'
: 'panAndTilt';
if (mode !== lastMode) {
lastMode = mode;
updateStartPosition(e);
}
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
if (pointerToLastPosition.size === 1 &&
Math.hypot(deltaX, deltaY) > 1) {
moved = true;
}
switch (mode) {
case 'pinch': {
const pinchDistance = computePinchDistance();
const delta = pinchDistance - startPinchDistance;
camHelper.dolly(cam.radius * 0.002 * -delta);
break;
}
case 'track': {
const s = cam.radius * 0.001;
camHelper.track(-deltaX * s, deltaY * s);
break;
}
case 'panAndTilt':
camHelper.panAndTilt(deltaX * 0.01, deltaY * 0.01);
break;
case 'doubleTapZoom':
camHelper.dolly(cam.radius * 0.002 * deltaY);
break;
}
render(elem);
};
const onUp = (e) => {
const numPointers = pointerToLastPosition.size;
pointerToLastPosition.delete(e.pointerId);
canvas.releasePointerCapture(e.pointerId);
if (numPointers === 1 && pointerToLastPosition.size === 0) {
doubleTapMode = false;
if (!moved) {
pickMeshes(e, cam, moved);
}
}
};
const kDoubleClickTimeMS = 300;
const onDown = (e) => {
elem.setPointerCapture(e.pointerId);
pointerToLastPosition.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointerToLastPosition.size === 1) {
moved = false;
if (!doubleTapMode) {
const now = performance.now();
const deltaTime = now - lastSingleTapTime;
if (deltaTime < kDoubleClickTimeMS) {
doubleTapMode = true;
}
lastSingleTapTime = now;
}
} else {
doubleTapMode = false;
}
updateStartPosition(e);
};
// Dolly when the user uses the wheel
const onWheel = (e) => {
e.preventDefault();
const helper = cam.getUpdateHelper();
helper.dolly(cam.radius * 0.001 * e.deltaY);
render(elem);
};
elem.addEventListener('pointerup', onUp);
elem.addEventListener('pointercancel', onUp);
elem.addEventListener('lostpointercapture', onUp);
elem.addEventListener('pointerdown', onDown);
elem.addEventListener('pointermove', onMove);
elem.addEventListener('wheel', onWheel);
return () => {
elem.removeEventListener('pointerup', onUp);
elem.removeEventListener('pointercancel', onUp);
elem.removeEventListener('lostpointercapture', onUp);
elem.removeEventListener('pointerdown', onDown);
elem.removeEventListener('pointermove', onMove);
elem.removeEventListener('wheel', onWheel);
};
}
addOrbitCameraEventListeners(orbitCamera, canvas);
const pickBuffer = device.createBuffer({
size: 4,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
let pickTexture;
async function pick(clipX, clipY, viewProjectionMatrix, pickableMeshes) {
const x = Math.round((clipX * 0.5 + 0.5) * canvas.width);
const y = Math.round((clipY * -0.5 + 0.5) * canvas.height);
const encoder = device.createCommandEncoder();
pickTexture = makeNewTextureIfSizeDifferent(
pickTexture,
canvas, // for size
'r32uint',
GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
);
renderToTexture(
canvasData,
encoder,
pickTexture,
pickPipeline,
viewProjectionMatrix,
pickableMeshes,
);
// Copy the texel under the pointer to pickBuffer
encoder.copyTextureToBuffer(
{ texture: pickTexture, origin: [x, y] },
{ buffer: pickBuffer },
[1, 1]
);
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
// Get the value from the pickBuffer
await pickBuffer.mapAsync(GPUMapMode.READ);
const id = new Uint32Array(pickBuffer.getMappedRange())[0];
pickBuffer.unmap();
return id;
}
const canvasData = {
orbitCamera,
setupPostProcess,
postProcess,
panelId,
};
canvasToData.set(canvas, canvasData);
}
{
const fVertices = createVertices(createFVertices(), 'f');
const node = addTRSSceneGraphNode('f', root, {
translation: [100, 75, 30],
rotation: [Math.PI, Math.PI * 0.33, 0],
scale: [0.5, 0.5, 0.5],
});
addMesh(node, fVertices, [1, 1, 1, 1]);
}
const cabinets = addTRSSceneGraphNode('cabinets', root);
// Add cabinets
for (let cabinetNdx = 0; cabinetNdx < kNumCabinets; ++cabinetNdx) {
addCabinet(cabinets, cabinetNdx);
}
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- to be filled out when we render
loadOp: 'clear',
storeOp: 'store',
},
],
depthStencilAttachment: {
// view: <- to be filled out when we render
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
};
let selectedMeshes = [];
const settings = {
fieldOfView: degToRad(60),
showMeshNodes: false,
showAllTRS: false,
};
// Presents a TRS to the UI. Letting set which TRS
// is being edited.
class TRSUIHelper {
#trs = new TRS();
constructor() {}
setTRS(trs) {
this.#trs = trs ?? new TRS();
}
get translationX() { return this.#trs.translation[0]; }
set translationX(x) { this.#trs.translation[0] = x; }
get translationY() { return this.#trs.translation[1]; }
set translationY(x) { this.#trs.translation[1] = x; }
get translationZ() { return this.#trs.translation[2]; }
set translationZ(x) { this.#trs.translation[2] = x; }
get rotationX() { return this.#trs.rotation[0]; }
set rotationX(x) { this.#trs.rotation[0] = x; }
get rotationY() { return this.#trs.rotation[1]; }
set rotationY(x) { this.#trs.rotation[1] = x; }
get rotationZ() { return this.#trs.rotation[2]; }
set rotationZ(x) { this.#trs.rotation[2] = x; }
get scaleX() { return this.#trs.scale[0]; }
set scaleX(x) { this.#trs.scale[0] = x; }
get scaleY() { return this.#trs.scale[1]; }
set scaleY(x) { this.#trs.scale[1] = x; }
get scaleZ() { return this.#trs.scale[2]; }
set scaleZ(x) { this.#trs.scale[2] = x; }
}
const trsUIHelper = new TRSUIHelper();
const radToDegOptions = { min: -180, max: 180, step: 1, converters: GUI.converters.radToDeg };
function meshUsesNode(mesh, node) {
if (!node) {
return false;
}
if (mesh.node === node) {
return true;
}
for (const child of node.children) {
if (meshUsesNode(mesh, child)) {
return true;
}
}
return false;
}
const kUnelected = '\u3000'; // full-width space
const kSelected = '➡️';
const prefixRE = new RegExp(`^(?:${kUnelected}|${kSelected})`);
let currentNode;
function setCurrentSceneGraphNode(node) {
currentNode = node;
trsUIHelper.setTRS(node?.source);
trsFolder.name(`orientation: ${node?.name ?? '--none--'}`);
trsFolder.updateDisplay();
showTRS();
// Mark which node is selected.
for (const b of nodeButtons) {
const name = b.button.getName().replace(prefixRE, '');
b.button.name(`${b.node === node ? kSelected : kUnelected}${name}`);
}
selectedMeshes = meshes.filter(mesh => meshUsesNode(mesh, node));
renderAll();
}
function renderAll() {
for (const canvas of canvasToData.keys()) {
render(canvas);
}
}
// \u00a0 is non-breaking space.
const threeSpaces = '\u00a0\u00a0\u00a0';
const barTwoSpaces = '\u00a0|\u00a0';
const plusDash = '\u00a0+-';
// add a scene graph node to the GUI and adds the appropriate
// prefix so it looks something like
//
// +-root
// | +-child
// | | +-child
// | +-child
// +-child
function addSceneGraphNodeToGUI(gui, node, last, prefix) {
const nodes = [];
if (node.source instanceof TRS) {
const label = `${prefix === undefined ? '' : `${prefix}${plusDash}`}${node.name}`;
nodes.push({
button: addButtonLeftJustified(
gui, label, () => setCurrentSceneGraphNode(node)),
node,
});
}
const childPrefix = prefix === undefined
? ''
: `${prefix}${last ? threeSpaces : barTwoSpaces}`;
nodes.push(...node.children.map((child, i) => {
const childLast = i === node.children.length - 1;
return addSceneGraphNodeToGUI(gui, child, childLast, childPrefix);
}));
return nodes.flat();
}
const uiElem = document.querySelector('#ui');
const gui = new GUI({
parent: uiElem,
});
gui.onChange(() => {
uiElem.classList.toggle('hide-ui', !gui.isOpen());
renderAll();
});
gui.addButton('new panel', addPanel);
gui.add(settings, 'showMeshNodes').onChange(showMeshNodes);
gui.add(settings, 'showAllTRS').onChange(showTRS);
gui.addButton('frame selected', frameSelected);
const trsFolder = gui.addFolder('orientation').listen();
const trsControls = [
trsFolder.add(trsUIHelper, 'translationX', -200, 200, 1),
trsFolder.add(trsUIHelper, 'translationY', -200, 200, 1),
trsFolder.add(trsUIHelper, 'translationZ', -200, 200, 1),
trsFolder.add(trsUIHelper, 'rotationX', radToDegOptions),
trsFolder.add(trsUIHelper, 'rotationY', radToDegOptions),
trsFolder.add(trsUIHelper, 'rotationZ', radToDegOptions),
trsFolder.add(trsUIHelper, 'scaleX', 0.1, 100),
trsFolder.add(trsUIHelper, 'scaleY', 0.1, 100),
trsFolder.add(trsUIHelper, 'scaleZ', 0.1, 100),
];
const nodesFolder = gui.addFolder('nodes');
const nodeButtons = addSceneGraphNodeToGUI(nodesFolder, root);
function showMeshNodes(show) {
for (const {node, button} of nodeButtons) {
if (node.name.includes('mesh')) {
button.show(show);
}
}
}
showMeshNodes(false);
const alwaysShow = new Set([0, 1, 2]);
function showTRS() {
const ui = nodeToUISettings.get(currentNode);
trsControls.forEach((trs, i) => {
const showThis = ui
? ui.trs?.indexOf(i) >= 0
: (settings.showAllTRS || alwaysShow.has(i));
trs.show(showThis);
});
}
let objectNdx = 0;
setCurrentSceneGraphNode(undefined);
function drawObject(ctx, vertices, matrix, color) {
const { pass, viewProjectionMatrix } = ctx;
const { vertexBuffer, numVertices } = vertices;
if (objectNdx === objectInfos.length) {
objectInfos.push(createObjectInfo());
}
const {
matrixValue,
colorValue,
idValue,
uniformBuffer,
uniformValues,
bindGroup,
} = objectInfos[objectNdx++];
mat4.multiply(viewProjectionMatrix, matrix, matrixValue);
colorValue.set(color);
idValue[0] = objectNdx;
// upload the uniform values to the uniform buffer
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup);
pass.draw(numVertices);
}
function makeNewTextureIfSizeDifferent(texture, size, format, usage) {
if (!texture ||
texture.width !== size.width ||
texture.height !== size.height) {
texture?.destroy();
texture = device.createTexture({
format,
size,
usage,
});
}
return texture;
}
function drawMesh(ctx, mesh) {
const { node, vertices, color } = mesh;
drawObject(ctx, vertices, node.worldMatrix, color);
}
function getViewProjectionMatrix(cam, canvas) {
const aspect = canvas.clientWidth / canvas.clientHeight;
const projection = mat4.perspective(
settings.fieldOfView,
aspect,
1, // zNear
2000, // zFar
);
const viewMatrix = mat4.inverse(cam.getCameraMatrix());
// combine the view and projection matrixes
return mat4.multiply(projection, viewMatrix);
}
function renderToTexture(
canvasData, encoder, target, pipeline, viewProjectionMatrix, meshes) {
objectNdx = 0;
renderPassDescriptor.colorAttachments[0].view = target.createView();
canvasData.depthTexture = makeNewTextureIfSizeDifferent(
canvasData.depthTexture,
target, // for size
'depth24plus',
GPUTextureUsage.RENDER_ATTACHMENT,
);
renderPassDescriptor.depthStencilAttachment.view = canvasData.depthTexture.createView();
{
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
const ctx = { pass, viewProjectionMatrix };
for (const mesh of meshes) {
drawMesh(ctx, mesh);
}
pass.end();
}
}
function render(canvas) {
const context = canvas.getContext('webgpu');
const canvasData = canvasToData.get(canvas);
const { orbitCamera, setupPostProcess, postProcess } = canvasData;
orbitCamera.updateWorldMatrix();
root.updateWorldMatrix();
const viewProjectionMatrix = getViewProjectionMatrix(orbitCamera, canvas);
const encoder = device.createCommandEncoder();
// Get the current texture from the canvas context and
// pass it as the texture to render to.
const canvasTexture = context.getCurrentTexture();
renderToTexture(
canvasData,
encoder,
canvasTexture,
pipeline,
viewProjectionMatrix,
meshes,
);
// draw selected objects to postTexture
{
canvasData.postTexture = makeNewTextureIfSizeDifferent(
canvasData.postTexture,
canvasTexture, // for size
canvasTexture.format,
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.TEXTURE_BINDING,
);
setupPostProcess(canvasData.postTexture);
renderPassDescriptor.colorAttachments[0].view = canvasData.postTexture.createView();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
const ctx = { pass, viewProjectionMatrix };
for (const mesh of selectedMeshes) {
drawMesh(ctx, mesh);
}
pass.end();
// Draw outline based on alpha of postTexture
// on to the canvasTexture
postProcess(encoder, undefined, canvasTexture);
}
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
function computeAABBForMesh(mesh) {
const mat = mesh.node.worldMatrix;
const p0 = mesh.vertices.aabb.min;
const p1 = mesh.vertices.aabb.max;
let min;
let max;
for (let i = 0; i < 8; ++i) {
const p = [
(i & 1) ? p0[0] : p1[0],
(i & 2) ? p0[1] : p1[1],
(i & 4) ? p0[2] : p1[2],
];
vec3.transformMat4(p, mat, p);
if (i === 0) {
min = p.slice();
max = p.slice();
} else {
vec3.min(min, p, min);
vec3.max(max, p, max);
}
}
return { min, max };
}
function expandAABBInPlace(aabb, otherAABB) {
vec3.min(aabb.min, otherAABB.min, aabb.min);
vec3.max(aabb.max, otherAABB.max, aabb.max);
}
function getAABBForSelectedMeshes() {
if (selectedMeshes.length === 0) {
return undefined;
}
const aabb = computeAABBForMesh(selectedMeshes[0]);
for (let i = 1; i < selectedMeshes.length; ++i) {
expandAABBInPlace(aabb, computeAABBForMesh(selectedMeshes[i]));
}
return aabb;
}
function getCanvasDataFromPanelId(panelId) {
for (const [canvas, data] of canvasToData.entries()) {
if (data.panelId === panelId) {
return {
canvas,
data,
};
}
}
return undefined;
}
function frameSelected() {
if (selectedMeshes.length === 0) {
return;
}
const panelId = api.activePanel?.id;
if (!panelId) {
return;
}
const canvasData = getCanvasDataFromPanelId(panelId);
if (!canvasData) {
return;
}
const { canvas, data: { orbitCamera } } = canvasData;
// get aabb bounds for the selected objects.
const aabb = getAABBForSelectedMeshes();
const extent = vec3.subtract(aabb.max, aabb.min);
const diameter = vec3.distance(aabb.min, aabb.max);
// compute how far we need to set the radius for the selected
// objects to be framed.
const aspect = canvas.clientWidth / canvas.clientHeight;
const fieldOfViewH = 2 * Math.atan(Math.tan(settings.fieldOfView) * aspect);
const fov = Math.min(fieldOfViewH, settings.fieldOfView);
const zoomScale = 1.5; // make it 1.5 times as large for some padding.
const halfSize = diameter * zoomScale * 0.5;
const distance = halfSize / Math.tan(fov * 0.5);
orbitCamera.radius = distance;
// point the camera at the center
const center = vec3.addScaled(aabb.min, extent, 0.5);
orbitCamera.setTarget(center);
render(canvas);
}
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
const width = entry.contentBoxSize[0].inlineSize;
const height = entry.contentBoxSize[0].blockSize;
canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
requestAnimationFrame(() => render(canvas));
}
});
const api = createDockview(host, {
createComponent: (options) => {
// options: { id: string, name: string } :contentReference[oaicite:1]{index=1}
switch (options.name) {
case 'scene-view': {
const element = document.createElement("canvas");
element.className = 'scene-view'
const context = element.getContext('webgpu');
context.configure({
device,
format: presentationFormat,
});
addPerCanvasInfo(element, options.id);
observer.observe(element);
return {
element,
init: () => {},
dispose: () => {
context.unconfigure();
observer.unobserve(element);
},
};
}
default:
throw new Error(`Unknown component: ${options.name}`);
}
},
});
let nextPanelId = 1;
function addPanel(extra = {}) {
const id = nextPanelId++;
const activePanel = api.activePanel;
api.addPanel({
id: `v${id}`,
component: "scene-view",
title: `View ${id}`,
...(activePanel && { position: {
referencePanel: activePanel.id,
direction: 'right',
}}),
...extra
});
}
addPanel();
addPanel({ position: { referencePanel: 'v1', direction: 'right' } });
{"name":"WebGPU: Editor Panes","settings":{},"filenames":["index.html","index.css","index.js"]}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment