Last active
January 15, 2026 07:47
-
-
Save greggman/d65308f2cc8aac3bd3b4bcae0832a28a to your computer and use it in GitHub Desktop.
WebGPU: Editor Panes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :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; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {"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