From 69e2be5778f6c3f1c36b155faa8ef4e48292e29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pons?= Date: Wed, 20 Aug 2025 18:05:00 +0200 Subject: [PATCH] Orbit Camera --- index.ts | 3 +- main.ts | 362 ++++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 263 insertions(+), 102 deletions(-) diff --git a/index.ts b/index.ts index 2cc66ed..1b60b5b 100644 --- a/index.ts +++ b/index.ts @@ -3,7 +3,6 @@ import index from './index.html'; const server = Bun.serve({ routes: { '/': index - }, - development: false + } }); console.log(server.url.href); \ No newline at end of file diff --git a/main.ts b/main.ts index e9f3e7d..89c3ed1 100644 --- a/main.ts +++ b/main.ts @@ -4,42 +4,42 @@ import { Stats } from 'stats.ts'; const shaderCode = ` struct VertexOut { - @builtin(position) position: vec4f, - @location(0) color: vec4f, - @location(1) uv: vec2f + @builtin(position) position: vec4f, + @location(0) color: vec4f } struct Star { - pos: vec3f, - lum: f32 + pos: vec3f, + lum: f32 +} +struct Uniform { + _View: mat4x4f, + _Projection: mat4x4f } -@group(0) @binding(0) var _ViewProjectionMatrix: mat4x4f; +@group(0) @binding(0) var uniforms: Uniform; @group(0) @binding(1) var stars: array; -const quad: array = array( - vec4f(0, 0, 0, 0), - vec4f(0, 1, 0, 0), - vec4f(1, 0, 0, 0), - vec4f(1, 1, 0, 0), -); - @vertex -fn vertex_main(@builtin(vertex_index) vertex_index: u32, @builtin(instance_index) instance_index: u32) -> VertexOut +fn vertex_main(@location(0) position: vec3f, @builtin(instance_index) instance_index: u32) -> VertexOut { - var star: Star = stars[instance_index]; - var output: VertexOut; + var star: Star = stars[instance_index]; + var output: VertexOut; - output.position = vec4f(star.pos, 0) + quad[vertex_index]; - output.color = vec4(star.lum, star.lum, star.lum, 1); - output.uv = vec2f(quad[vertex_index].x, quad[vertex_index].y); + var star_pos = uniforms._View * vec4f(star.pos, 1.0); + var billboard_pos = star_pos + vec4f(position, 0.0); - return output; + output.position = uniforms._Projection * billboard_pos; + output.color = vec4(star.lum, star.lum, star.lum, 1); + + return output; } @fragment fn fragment_main(fragData: VertexOut) -> @location(0) vec4f { - return fragData.color * distance(fragData.uv, vec2f(0.5, 0.5)); + //var dist = distance(fragData.uv, vec2f(0.5, 0.5)); + //return vec4f(1 - dist, 1 - dist, 1 - dist, dist); + return vec4f(1.0, 1.0, 1.0, 1.0); } `; @@ -60,6 +60,11 @@ type Constellation = { description: string | null; semantics: string[] | null; }; +type Position = { + x: number; + y: number; + z: number; +} class Matrix4x4 { @@ -136,6 +141,30 @@ class Matrix4x4 return mat; } + identity(value: number = 1): this + { + this._view[0] = value; + this._view[1] = 0; + this._view[2] = 0; + this._view[3] = 0; + + this._view[4] = 0; + this._view[5] = value; + this._view[6] = 0; + this._view[7] = 0; + + this._view[8] = 0; + this._view[9] = 0; + this._view[10] = value; + this._view[11] = 0; + + this._view[12] = 0; + this._view[13] = 0; + this._view[14] = 0; + this._view[15] = value; + + return this; + } translate(x: number, y: number, z: number): this { return this.multiply(this, Matrix4x4.translation(x, y, z)); @@ -214,7 +243,7 @@ class Matrix4x4 return this; } - inverse() + invert() { const m00 = this._view[0 * 4 + 0]!; const m01 = this._view[0 * 4 + 1]!; @@ -292,43 +321,67 @@ class Matrix4x4 return this; } - camera(transform: { - position: { - x: number; - y: number; - z: number; - }, - yaw: number, - pitch: number - }): this + lookat(eye: Position, target: Position): this { - const cosPitch = Math.cos(transform.pitch); - const sinPitch = Math.sin(transform.pitch); - const cosYaw = Math.cos(transform.yaw); - const sinYaw = Math.sin(transform.yaw); + const forward = { //zAxis + x: eye.x - target.x, + y: eye.y - target.y, + z: eye.z - target.z + }; - this._view[0] = cosYaw; - this._view[1] = 0; - this._view[2] = -sinYaw; - this._view[3] = 0; + // Normalize + const forwardLen = Math.sqrt(forward.x * forward.x + forward.y * forward.y + forward.z * forward.z); + forward.x /= forwardLen; + forward.y /= forwardLen; + forward.z /= forwardLen; + + const right = { //xAxis + x: 1 * forward.z - 0 * forward.y, + y: 0 * forward.x - 0 * forward.z, + z: 0 * forward.y - 1 * forward.x + }; - this._view[4] = sinYaw * sinPitch; - this._view[5] = cosPitch; - this._view[6] = cosYaw * sinPitch; - this._view[7] = 0; + // Normalize + const rightLen = Math.sqrt(right.x * right.x + right.y * right.y + right.z * right.z); + right.x /= rightLen; + right.y /= rightLen; + right.z /= rightLen; - this._view[8] = sinYaw * cosPitch; - this._view[9] = -sinPitch; - this._view[10] = cosYaw * cosPitch; + const up = { //yAxis + x: forward.y * right.z - forward.z * right.y, + y: forward.z * right.x - forward.x * right.z, + z: forward.x * right.y - forward.y * right.x + }; + + // Normalize + const upLen = Math.sqrt(up.x * up.x + up.y * up.y + up.z * up.z); + up.x /= upLen; + up.y /= upLen; + up.z /= upLen; + + this._view[ 0] = right.x; + this._view[ 1] = right.y; + this._view[ 2] = right.z; + this._view[ 3] = 0; + + this._view[ 4] = up.x; + this._view[ 5] = up.y; + this._view[ 6] = up.z; + this._view[ 7] = 0; + + this._view[ 8] = forward.x; + this._view[ 9] = forward.y; + this._view[10] = forward.z; this._view[11] = 0; - this._view[12] = transform.position.x; - this._view[13] = transform.position.y; - this._view[14] = transform.position.z; + this._view[12] = eye.x; + this._view[13] = eye.y; + this._view[14] = eye.z; this._view[15] = 1; return this; } + perspective(aspect: number, fovY: number, near: number, far: number): this { const f = Math.tan(Math.PI * 0.5 - 0.5 * fovY); @@ -357,23 +410,120 @@ class Matrix4x4 return this; } - copy(buffer: ArrayBuffer, offset?: number) - { - new Float32Array(buffer).set(this._view, offset); - } get view() { return this._view; } } +class Inputs +{ + static inputs: Record = {}; + private static _movementX = 0; + private static _movementY = 0; + private static _zoom = 0; + private static _dragging = false; + static init(eventTarget: HTMLElement) + { + const dragstart = (e: MouseEvent) => { + if(e.buttons & 1) + { + Inputs._dragging = true; + window.addEventListener('mousemove', dragmove); + window.addEventListener('mouseup', dragend); + } + }; + const dragmove = (e: MouseEvent) => { + Inputs._movementX += e.movementX; + Inputs._movementY += e.movementY; + }; + const dragend = () => { + Inputs._dragging = false; + window.removeEventListener('mousemove', dragmove); + window.removeEventListener('mouseup', dragend); + }; + eventTarget.addEventListener('keydown', (e: KeyboardEvent) => { + Inputs.inputs[e.key.toLowerCase()] = true; + }); + eventTarget.addEventListener('keyup', (e: KeyboardEvent) => { + Inputs.inputs[e.key.toLowerCase()] = false; + }); + eventTarget.addEventListener('mousedown', dragstart); + eventTarget.addEventListener('wheel', (e: WheelEvent) => Inputs._zoom += e.deltaY); + } + static get dragging() + { + return Inputs._dragging; + } + static get movementX() + { + const movement = Inputs._movementX; + Inputs._movementX = 0; + return movement; + } + static get movementY() + { + const movement = Inputs._movementY; + Inputs._movementY = 0; + return movement; + } + static get zoom() + { + const zoom = Inputs._zoom; + Inputs._zoom = 0; + return zoom; + } +} +class Camera +{ + pitch = 0; + yaw = 0; + distance = 25 + view = new Matrix4x4(); + + constructor(distance?: number, yaw?: number, pitch?: number) + { + this.pitch = pitch ?? 0; + this.yaw = yaw ?? 0; + this.distance = distance ?? 25; + } + update(elapsed: number, target: GPUBuffer) + { + elapsed /= 1 << 15; + this.yaw -= Inputs.movementX * elapsed; + this.pitch += Inputs.movementY * elapsed; + + this.yaw = (this.yaw + Math.PI * 2) % (Math.PI * 2); + this.pitch = clamp(this.pitch, -Math.PI / 2, Math.PI / 2); + + const pitchCos = Math.cos(this.pitch), pitchSin = Math.sin(this.pitch); + const yawCos = Math.cos(this.yaw), yawSin = Math.sin(this.yaw); + + this.distance = clamp(this.distance + (Inputs.zoom * elapsed * 1000), 1, 10000); + + const x = this.distance * yawSin * pitchCos; + const y = this.distance * yawSin * pitchSin; + const z = this.distance * yawCos; + + device!.queue.writeBuffer(target, 0, this.view.lookat({ x, y, z }, { x: 0, y: 0, z: 0 }).view.buffer); + } +} //@ts-ignore const stars: Star[] = untypedStars; const constellations: Constellation[] = untypedConstellations; -let canvas: HTMLCanvasElement, device: GPUDevice | undefined, pipeline: GPURenderPipeline, context: GPUCanvasContext | null, bindGroup: GPUBindGroup, viewport: DOMRect; -let camera = { projection: new Matrix4x4(), view: new Matrix4x4(), transform: { position: { x: 0, y: 0, z: 0 }, yaw: 0, pitch: 0 }, final: new Matrix4x4(), _viewDirty: true, _projectionDirty: true }; -let stats: Stats; +let canvas: HTMLCanvasElement, device: GPUDevice | undefined, pipeline: GPURenderPipeline, context: GPUCanvasContext | null, bindGroup: GPUBindGroup, viewport: DOMRect, uniforms: GPUBuffer, indirect: GPUBuffer; +let vertex: GPUBuffer, index: GPUBuffer; +let camera: Camera, projectionMatrix = new Matrix4x4(), lastTime: DOMHighResTimeStamp; +let stats: Stats, dom: HTMLElement; +function clamp(x: number, min: number, max: number): number +{ + if(x < min) + return min; + if(x > max) + return max; + return x; +} function fillBuffer(buffer: ArrayBuffer) { const array = new Float32Array(buffer); @@ -391,36 +541,15 @@ function resize() { viewport = document.body.getBoundingClientRect(); canvas!.width = viewport.width; canvas!.height = viewport.height; - camera.projection.perspective(viewport.width / viewport.height, 60, 0.1, 2000); -} -function computeViewProjectionMatrix() -{ - let _dirty = false; - if(camera._viewDirty) - { - _dirty = true; - camera.view.camera(camera.transform).inverse(); - camera._viewDirty = false; - } - if(camera._projectionDirty) - { - _dirty = true; - camera.projection.perspective(viewport.width / viewport.height, 60, 0.1, 2000); - camera._projectionDirty = false; - } - if(_dirty) - { - camera.final.multiply(camera.projection, camera.view); - } + device!.queue.writeBuffer(uniforms, 64, projectionMatrix.perspective(viewport.width / viewport.height, 60, 0.1, 2000000).view.buffer); } + async function init() { canvas = document.createElement("canvas"); context = canvas.getContext('webgpu'); - document.body.appendChild(canvas); - resize(); - document.addEventListener('resize', resize); + Inputs.init(canvas); stats = new Stats(); document.body.appendChild(stats.dom); @@ -430,33 +559,30 @@ async function init() if(device && context) { - context.configure({ - device, - format: navigator.gpu.getPreferredCanvasFormat(), - alphaMode: 'premultiplied', - }); - const shader = device.createShaderModule({ code: shaderCode, label: 'default-shader' }); - const buffer = device.createBuffer({ size: stars.length * 4 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true, label: 'instanced_buffer' }); - const arrayBuffer = buffer.getMappedRange(); - + const buffer = device.createBuffer({ size: stars.length * 4 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, label: 'instanced_buffer' }); + const arrayBuffer = new ArrayBuffer(buffer.size); fillBuffer(arrayBuffer); - - buffer.unmap(); device.queue.writeBuffer(buffer, 0, arrayBuffer); + + uniforms = device.createBuffer({ size: 16 * 4 * 2, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, label: 'uniforms_buffer' }); + camera = new Camera(); - const uniformBuffer = device.createBuffer({ size: 16 * 4 /* matrix4x4 */, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, mappedAtCreation: true, label: 'uniform_buffer' }); - const uniformArrayBuffer = uniformBuffer.getMappedRange(); + indirect = device.createBuffer({ size: 5 * 4, usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST, label: 'indirect_buffer' }); + const indirectArray = new Uint32Array([ 6, stars.length, 0, 0, 0 ]); + const indirectSignedArray = new Int32Array(indirectArray.buffer); + indirectSignedArray[3] = 0; + device.queue.writeBuffer(indirect, 0, indirectArray.buffer); - computeViewProjectionMatrix(); - camera.final.copy(uniformArrayBuffer); + vertex = device.createBuffer({ size: 3 * 4 * 4, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, label: 'vertex_buffer' }); + device.queue.writeBuffer(vertex, 0, new Float32Array([ -0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.5, 0.5, 0.0, -0.5, 0.5, 0.0, ])); - uniformBuffer.unmap(); - device.queue.writeBuffer(uniformBuffer, 0, uniformArrayBuffer); + index = device.createBuffer({ size: 6 * 2, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, label: 'index_buffer' }); + device.queue.writeBuffer(index, 0, new Uint16Array([ 0, 1, 2, 0, 2, 3 ])); const bindGroupLayout = device.createBindGroupLayout({ entries: [{ @@ -478,7 +604,7 @@ async function init() layout: bindGroupLayout, entries: [{ binding: 0, - resource: { buffer: uniformBuffer, } + resource: { buffer: uniforms, } }, { binding: 1, resource: { buffer: buffer, } @@ -491,7 +617,15 @@ async function init() }), vertex: { module: shader, - entryPoint: 'vertex_main' + entryPoint: 'vertex_main', + buffers: [{ + attributes: [{ + format: 'float32x3', + offset: 0, + shaderLocation: 0 + }], + arrayStride: 4 * 3, + }] }, fragment: { targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }], @@ -501,9 +635,30 @@ async function init() primitive: { topology: 'triangle-list', cullMode: 'front', + frontFace: 'cw' } }); + document.body.appendChild(canvas); + resize(); + document.addEventListener('resize', resize); + + context.configure({ + device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: 'premultiplied', + }); + + dom = document.createElement('pre'); + Object.assign(dom.style, { + "position": "absolute", + "right": "0px", + "top": "0px", + "color": "white" + }); + document.body.append(dom); + + lastTime = performance.now(); requestAnimationFrame(render); } else @@ -513,6 +668,8 @@ async function init() } function render(time: DOMHighResTimeStamp) { + const elapsed = time - lastTime; + lastTime = time; stats.begin(); const commandEncoder = device!.createCommandEncoder({ label: 'command-encoder' }); const pass = commandEncoder.beginRenderPass({ label: 'render-pass', colorAttachments: [{ @@ -522,10 +679,15 @@ function render(time: DOMHighResTimeStamp) view: context!.getCurrentTexture().createView({ label: 'render-target', usage: GPUTextureUsage.RENDER_ATTACHMENT }), }], }); + camera.update(elapsed, uniforms); + dom.textContent = `Pitch: ${camera.pitch}\nYaw: ${camera.yaw}\nDistance: ${camera.distance}`; + pass.setPipeline(pipeline!); pass.setBindGroup(0, bindGroup); + pass.setVertexBuffer(0, vertex); + pass.setIndexBuffer(index, 'uint16'); pass.setViewport(viewport.x, viewport.y, viewport.width, viewport.height, 0, 1); - pass.draw(4, stars.length, 0, 0); + pass.drawIndexedIndirect(indirect, 0); pass.end(); device!.queue.submit([ commandEncoder.finish() ]);