diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/index.html b/apps/typegpu-docs/src/examples/rendering/heatmap/index.html new file mode 100644 index 000000000..aa8cc321b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/index.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/index.ts new file mode 100644 index 000000000..e0fd6524b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/index.ts @@ -0,0 +1,30 @@ +import * as d from 'typegpu/data'; + +import { Plotter } from './prism/src/plotter.ts'; +import { predefinedSurfaces } from './prism/src/examples/surfaces.ts'; +import { Scalers } from './prism/src/scalers.ts'; + +const canvas = document.querySelector('canvas') as HTMLCanvasElement; + +const plotter = new Plotter(canvas); +await plotter.init(); + +plotter.addPlots( + [ + predefinedSurfaces.normal, + ], + { + xScaler: Scalers.MinMaxScaler, + yScaler: Scalers.SignPreservingScaler, + zScaler: Scalers.MinMaxScaler, + xZeroPlane: false, + zZeroPlane: false, + yZeroPlane: true, + topology: 'all', + basePlanesTranslation: d.vec3f(0, -0.01, 0), + basePlanesScale: d.vec3f(2.01), + basePlotsTranslation: d.vec3f(), + basePlotsScale: d.vec3f(2, 1, 2), + }, +); +plotter.startRenderLoop(); diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/meta.json b/apps/typegpu-docs/src/examples/rendering/heatmap/meta.json new file mode 100644 index 000000000..6bc9be33a --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Heatmap", + "category": "rendering", + "tags": ["experimental", "3d", "rasterization"] +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/constants.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/constants.ts new file mode 100644 index 000000000..89c64c0bb --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/constants.ts @@ -0,0 +1,53 @@ +import * as d from 'typegpu/data'; + +import type { + CameraConfig, + GridConfig, + PlotConfig, + ScaleTransform, +} from './types.ts'; +import { Scalers } from './scalers.ts'; + +export const EPS = 1e-6; +export const DEFAULT_CAMERA_CONFIG: CameraConfig = { + zoomable: true, + draggable: true, + position: d.vec4f(7, 4, 7, 1), + target: d.vec3f(), + up: d.vec3f(0, 1, 0), + fov: Math.PI / 4, + near: 0.1, + far: 1000, + orbitSensitivity: 0.005, + zoomSensitivity: 0.05, + maxZoom: 7, +}; + +export const DEFAULT_PLOT_CONFIG: PlotConfig = { + xScaler: Scalers.IdentityScaler, + yScaler: Scalers.IdentityScaler, + zScaler: Scalers.IdentityScaler, + xZeroPlane: false, + yZeroPlane: false, + zZeroPlane: false, + topology: 'all', + basePlanesTranslation: d.vec3f(0, -0.01, 0), + basePlanesScale: d.vec3f(2.01), + basePlotsTranslation: d.vec3f(), + basePlotsScale: d.vec3f(2), +}; + +export const DEFAULT_PLANE_COLOR = d.vec4f(d.vec3f(0.29, 0.21, 0.47), 0.5); +export const PLANE_GRID_CONFIG: GridConfig = { + nx: 2, + nz: 2, + xRange: { min: -1, max: 1 }, + zRange: { min: -1, max: 1 }, + yCallback: () => 0, + colorCallback: () => DEFAULT_PLANE_COLOR, + edgeColorCallback: () => d.vec4f(), +}; +export const IDENTITY_SCALE_TRANSFORM: ScaleTransform = { + offset: d.vec3f(), + scale: d.vec3f(1), +}; diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/event-handler.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/event-handler.ts new file mode 100644 index 000000000..157025c6a --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/event-handler.ts @@ -0,0 +1,167 @@ +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import * as m from 'wgpu-matrix'; + +import type { CameraConfig } from './types.ts'; + +export class EventHandler { + #canvas: HTMLCanvasElement; + #isDragging = false; + #prevX = 0; + #prevY = 0; + #orbitRadius: number; + #orbitYaw: number; + #orbitPitch: number; + #cameraConfig: CameraConfig; + #cameraViewMatrix!: d.m4x4f; + #cameraChanged = false; + #handlersMap; + + constructor(canvas: HTMLCanvasElement, cameraConfig: CameraConfig) { + this.#canvas = canvas; + this.#cameraConfig = cameraConfig; + + const cameraPosition = cameraConfig.position; + this.#orbitRadius = std.length(cameraPosition.xyz); + this.#orbitYaw = Math.atan2( + cameraPosition.x, + cameraPosition.z, + ); + this.#orbitPitch = Math.asin( + cameraPosition.y / this.#orbitRadius, + ); + + this.#handlersMap = new Map(); + } + + setup() { + const canvas = this.#canvas; + const handlersMap = this.#handlersMap; + + handlersMap.set('contextmenu', this.#preventDefault.bind(this)); + canvas.addEventListener('contextmenu', handlersMap.get('contextmenu')); + + if (this.#cameraConfig.zoomable) { + handlersMap.set('wheel', this.#wheelEventListener.bind(this)); + canvas.addEventListener('wheel', handlersMap.get('wheel'), { + passive: false, + }); + } + + if (this.#cameraConfig.draggable) { + handlersMap.set('mousedown', this.#mouseDownEventListener.bind(this)); + canvas.addEventListener('mousedown', this.#handlersMap.get('mousedown')); + + handlersMap.set('mousemove', this.#mouseMoveEventListener.bind(this)); + canvas.addEventListener('mousemove', this.#handlersMap.get('mousemove')); + + handlersMap.set('mouseup', this.#mouseUpEventListener.bind(this)); + canvas.addEventListener('mouseup', this.#handlersMap.get('mouseup')); + } + } + + resetCameraChangedFlag() { + this.#cameraChanged = false; + } + + get cameraChanged() { + return this.#cameraChanged; + } + + get cameraViewMatrix() { + return this.#cameraViewMatrix; + } + + #getCoordinalesfromPolars(): d.v4f { + const orbitRadius = this.#orbitRadius; + const orbitYaw = this.#orbitYaw; + const orbitPitch = this.#orbitPitch; + + return d.vec4f( + orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch), + orbitRadius * Math.sin(orbitPitch), + orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch), + 1, + ); + } + + #updateCameraOrbit(dx: number, dy: number) { + const orbitYaw = this.#orbitYaw - dx * this.#cameraConfig.orbitSensitivity; + const maxPitch = Math.PI / 2 - 0.01; + const orbitPitch = std.clamp( + this.#orbitPitch + + dy * this.#cameraConfig.orbitSensitivity, + -maxPitch, + maxPitch, + ); + + const newCameraPos = this.#getCoordinalesfromPolars(); + const newView = m.mat4.lookAt( + newCameraPos, + this.#cameraConfig.target, + this.#cameraConfig.up, + d.mat4x4f(), + ); + + this.#orbitYaw = orbitYaw; + this.#orbitPitch = orbitPitch; + this.#cameraChanged = true; + this.#cameraViewMatrix = newView; + } + + #updateCameraZoom(dy: number): void { + this.#orbitRadius = Math.max( + this.#cameraConfig.maxZoom, + this.#orbitRadius + dy * this.#cameraConfig.zoomSensitivity, + ); + + const newCameraPos = this.#getCoordinalesfromPolars(); + const newView = m.mat4.lookAt( + newCameraPos, + this.#cameraConfig.target, + this.#cameraConfig.up, + d.mat4x4f(), + ); + + this.#cameraChanged = true; + this.#cameraViewMatrix = newView; + } + + #preventDefault(event: Event) { + event.preventDefault(); + } + + #mouseUpEventListener() { + this.#isDragging = false; + } + + #mouseMoveEventListener(event: MouseEvent) { + if (!this.#isDragging) return; + + const dx = event.clientX - this.#prevX; + const dy = event.clientY - this.#prevY; + this.#prevX = event.clientX; + this.#prevY = event.clientY; + + this.#updateCameraOrbit(dx, dy); + } + + #mouseDownEventListener(event: MouseEvent) { + if (event.button === 0) { + this.#isDragging = true; + } + this.#prevX = event.clientX; + this.#prevY = event.clientY; + } + + #wheelEventListener(event: WheelEvent) { + this.#preventDefault(event); + this.#updateCameraZoom(event.deltaY); + } + + destroy() { + for (const [event, handler] of this.#handlersMap) { + this.#canvas.removeEventListener(event, handler); + } + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/examples/surfaces.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/examples/surfaces.ts new file mode 100644 index 000000000..40887c985 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/examples/surfaces.ts @@ -0,0 +1,75 @@ +import * as d from 'typegpu/data'; + +import * as c from '../constants.ts'; +import { GridSurface } from '../grid.ts'; + +const defaultRasterizationColor = d.vec4f(0, 1, 0.75, 1); + +const mistyMountains = new GridSurface({ + nx: 49, + nz: 49, + xRange: { min: -5, max: 5 }, + zRange: { min: -5, max: 5 }, + yCallback: () => Math.random() * 4, + colorCallback: (y: number) => d.vec4f(d.vec3f(y), 1), + edgeColorCallback: (y: number) => defaultRasterizationColor, +}); + +const logXZ = new GridSurface({ + nx: 49, + nz: 49, + xRange: { min: -1, max: 9 }, + zRange: { min: -1, max: 9 }, + yCallback: (x: number, z: number) => Math.log(Math.abs(x * z) + c.EPS), + colorCallback: (y: number) => d.vec4f(d.vec3f(y), 1), + edgeColorCallback: (y: number) => defaultRasterizationColor, +}); + +const ripple = new GridSurface({ + nx: 101, + nz: 101, + xRange: { min: -1, max: 1 }, + zRange: { min: -1, max: 1 }, + yCallback: (x: number, z: number) => 1 + Math.sin(10 * (x ** 2 + z ** 2)), + colorCallback: (y: number) => d.vec4f(d.vec3f(y / 3), 1), + edgeColorCallback: (y: number) => defaultRasterizationColor, +}); + +const normal = new GridSurface({ + nx: 101, + nz: 101, + xRange: { min: -5, max: 5 }, + zRange: { min: -5, max: 5 }, + yCallback: (x: number, z: number) => Math.exp(-(x ** 2 + z ** 2) / 2), + colorCallback: (y: number) => d.vec4f(d.vec3f(y), 1), + edgeColorCallback: (y: number) => defaultRasterizationColor, +}); + +const powerOfTwo = new GridSurface({ + nx: 101, + nz: 101, + xRange: { min: -2, max: 2 }, + zRange: { min: -2, max: 2 }, + yCallback: (x: number, z: number) => 2 ** Math.abs(x * z), + colorCallback: (y: number) => d.vec4f(0.5, 0, 0, 0.5), + edgeColorCallback: (y: number) => defaultRasterizationColor, +}); + +const discreteMul = new GridSurface({ + nx: 500, + nz: 500, + xRange: { min: -5, max: 5 }, + zRange: { min: -5, max: 5 }, + yCallback: (x: number, z: number) => Math.floor(x * z), + colorCallback: (y: number) => d.vec4f(d.vec3f(y), 1), + edgeColorCallback: (y: number) => defaultRasterizationColor, +}); + +export const predefinedSurfaces = { + mistyMountains, + logXZ, + ripple, + normal, + powerOfTwo, + discreteMul, +}; diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/grid.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/grid.ts new file mode 100644 index 000000000..c14a5b5bc --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/grid.ts @@ -0,0 +1,122 @@ +import * as d from 'typegpu/data'; + +import type * as s from './structures.ts'; +import type { GridConfig, ISurface } from './types.ts'; + +export class GridSurface implements ISurface { + #gridConfig: GridConfig; + + constructor( + gridConfig: GridConfig, + ) { + this.#gridConfig = gridConfig; + } + + get gridConfig(): GridConfig { + return this.#gridConfig; + } + + set gridConfig(gridConfig: GridConfig) { + this.#gridConfig = gridConfig; + } + + getVertexBufferData(): d.Infer[] { + let vertices = this.#createGrid(); + vertices = this.#populateGridY(vertices); + vertices = this.#populateGridColor(vertices); + vertices = this.#populateGridEdgeColor(vertices); + return vertices; + } + + getIndexBufferData(): number[] { + return this.#createGridIndexArray( + this.#gridConfig.nx, + this.#gridConfig.nz, + ); + } + + /** + * returns 1D array of vertices + * 6 --- 7 --- 8 + * | | | + * 3 --- 4 --- 5 + * | | | + * -> 0 --- 1 --- 2 + * + * with x,z coordinates filled + */ + #createGrid(): d.Infer[] { + const { nx, nz, xRange, zRange } = this.#gridConfig; + const dz = (zRange.max - zRange.min) / (nz - 1); + const dx = (xRange.max - xRange.min) / (nx - 1); + + const zs = Array.from({ length: nx }, (_, i) => zRange.min + i * dz); + const xs = Array.from({ length: nz }, (_, j) => xRange.min + j * dx); + + const vertices = zs.flatMap((z) => + xs.map((x) => ({ + position: d.vec4f(x, 0, z, 1), + color: d.vec4f(), + edgeColor: d.vec4f(), + })) + ); + + return vertices; + } + + #createGridIndexArray(nx: number, nz: number): number[] { + const indices = []; + + for (let i = 0; i < nz - 1; i++) { + for (let j = 0; j < nx - 1; j++) { + const topLeft = i * nx + j; + const topRight = i * nx + (j + 1); + const bottomLeft = (i + 1) * nx + j; + const bottomRight = (i + 1) * nx + (j + 1); + + indices.push( + topLeft, + bottomLeft, + bottomRight, + topLeft, + bottomRight, + topRight, + ); + } + } + + return indices; + } + + #populateGridColor( + vertices: d.Infer[], + ): d.Infer[] { + return vertices.map((vertex) => ({ + ...vertex, + color: this.#gridConfig.colorCallback(vertex.position.y), + })); + } + + #populateGridEdgeColor( + vertices: d.Infer[], + ): d.Infer[] { + return vertices.map((vertex) => ({ + ...vertex, + edgeColor: this.#gridConfig.edgeColorCallback(vertex.position.y), + })); + } + + #populateGridY( + vertices: d.Infer[], + ): d.Infer[] { + return vertices.map((vertex) => ({ + ...vertex, + position: d.vec4f( + vertex.position.x, + this.#gridConfig.yCallback(vertex.position.x, vertex.position.z), + vertex.position.z, + 1, + ), + })); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/layouts.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/layouts.ts new file mode 100644 index 000000000..2353a44ba --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/layouts.ts @@ -0,0 +1,11 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; + +import * as s from './structures.ts'; + +export const layout = tgpu.bindGroupLayout({ + camera: { uniform: s.Camera }, + transform: { uniform: s.Transform }, +}); + +export const vertexLayout = tgpu.vertexLayout(d.arrayOf(s.Vertex)); diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/plotter.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/plotter.ts new file mode 100644 index 000000000..e7aad9826 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/plotter.ts @@ -0,0 +1,421 @@ +import tgpu from 'typegpu'; +import type { + IndexFlag, + TgpuBindGroup, + TgpuBuffer, + TgpuRenderPipeline, + TgpuRoot, + VertexFlag, +} from 'typegpu'; +import * as d from 'typegpu/data'; +import type { + CameraConfig, + IPlotter, + IScaler, + ISurface, + PlotConfig, +} from './types.ts'; +import { + fragmentFn, + lineFragmentFn, + lineVertexFn, + vertexFn, +} from './shaders.ts'; +import { layout, vertexLayout } from './layouts.ts'; +import * as c from './constants.ts'; +import { EventHandler } from './event-handler.ts'; +import { ResourceKeeper } from './resource-keeper.ts'; +import type * as s from './structures.ts'; +import { createLineListFromTriangleList } from './utils.ts'; + +export class Plotter implements IPlotter { + readonly #context: GPUCanvasContext; + readonly #canvas: HTMLCanvasElement; + readonly #presentationFormat: GPUTextureFormat; + #cameraConfig: CameraConfig; + readonly #eventHandler: EventHandler; + #root!: TgpuRoot; + #resourceKeeper!: ResourceKeeper; + #triangleRenderPipeline!: TgpuRenderPipeline; + #lineRenderPipeline!: TgpuRenderPipeline; + #plotConfig!: PlotConfig; + #bindedFrameFunction!: () => void; + #keepRendering = false; + readonly #coordsToNumMap = new Map([ + ['x', 0], + ['y', 1], + ['z', 2], + ]); + + constructor(canvas: HTMLCanvasElement, cameraConfig?: CameraConfig) { + this.#canvas = canvas; + this.#context = canvas.getContext('webgpu') as GPUCanvasContext; + this.#presentationFormat = navigator.gpu.getPreferredCanvasFormat(); + this.#cameraConfig = cameraConfig ?? c.DEFAULT_CAMERA_CONFIG; + this.#eventHandler = new EventHandler(this.#canvas, this.#cameraConfig); + } + + async init(): Promise { + this.#root = await tgpu.init(); + + this.#context.configure({ + device: this.#root.device, + format: this.#presentationFormat, + alphaMode: 'premultiplied', + }); + + this.#resourceKeeper = new ResourceKeeper( + this.#root, + this.#canvas, + this.#presentationFormat, + ); + + this.updateCamera(this.#cameraConfig); + + this.#createRenderPipelines(); + } + + addPlots(surfaces: ISurface[], options?: PlotConfig): void { + this.#plotConfig = options ?? c.DEFAULT_PLOT_CONFIG; + const { + xScaler, + yScaler, + zScaler, + basePlanesTranslation, + basePlanesScale, + basePlotsTranslation, + basePlotsScale, + } = this.#plotConfig; + + const vertices = surfaces.flatMap((surface) => + surface.getVertexBufferData() + ); + + const [X, Y, Z] = [0, 1, 2].map((coord) => + vertices.map((vertex) => vertex.position[coord]) + ); + + let xOffset = 0; + let yOffset = 0; + let zOffset = 0; + let xScale = 1; + let yScale = 1; + let zScale = 1; + + if (xScaler.type === 'affine' && X.length) { + ({ offset: xOffset, scale: xScale } = xScaler.fit(X)); + } + if (yScaler.type === 'affine' && Y.length) { + ({ offset: yOffset, scale: yScale } = yScaler.fit(Y)); + } + if (zScaler.type === 'affine' && Z.length) { + ({ offset: zOffset, scale: zScale } = zScaler.fit(Z)); + } + + this.#resourceKeeper.updateTransformUniform({ + offset: d.vec3f(xOffset, yOffset, zOffset).mul(basePlotsScale).add( + basePlotsTranslation, + ), + scale: d.vec3f(xScale, yScale, zScale).mul(basePlotsScale), + }); + this.#resourceKeeper.updatePlanesTransformUniforms( + { + offset: d.vec3f(xOffset, yOffset, zOffset).mul(basePlotsScale) + .add( + basePlanesTranslation, + ), + scale: basePlanesScale, + }, + ); + + let vertexBuffers = surfaces.map((surface) => + surface.getVertexBufferData() + ); + + if (xScaler.type === 'non-affine') { + vertexBuffers = this.#applyNonAffine(vertexBuffers, xScaler, 'x'); + } + if (yScaler.type === 'non-affine') { + vertexBuffers = this.#applyNonAffine(vertexBuffers, yScaler, 'y'); + } + if (zScaler.type === 'non-affine') { + vertexBuffers = this.#applyNonAffine(vertexBuffers, zScaler, 'z'); + } + + this.#resourceKeeper.createSurfaceStackResources( + surfaces.map((surface, i) => { + const triangleIndices = surface.getIndexBufferData(); + return { + vertices: vertexBuffers[i], + triangleIndices, + lineIndices: createLineListFromTriangleList(triangleIndices), + }; + }), + ); + } + + resetPlots(): void { + this.#resourceKeeper.resetSurfaceStack(); + } + + startRenderLoop(): void { + this.#keepRendering = true; + this.#eventHandler.setup(); + + const resizeObserver = new ResizeObserver(() => + this.#resourceKeeper.updateDepthAndMsaa() + ); + resizeObserver.observe(this.#canvas); + this.#canvas; + + if (!this.#bindedFrameFunction) { + this.#bindedFrameFunction = this.#frame.bind(this); + } + this.#frame(); + } + + stopRenderLoop(): void { + this.#keepRendering = false; + } + + updateCamera(cameraConfig: CameraConfig): void { + this.#cameraConfig = cameraConfig; + this.#resourceKeeper.updateCameraUniform( + this.#cameraConfig, + this.#canvas.clientWidth / this.#canvas.clientHeight, + ); + } + + #applyNonAffine( + vertexBuffers: (d.Infer[])[], + scaler: Extract, + coord: 'x' | 'y' | 'z', + ): (d.Infer[])[] { + return vertexBuffers.map((vertices) => + vertices.map((vertex) => { + const newPos = vertex.position; + //biome-ignore lint/style/noNonNullAssertion: it's hardcoded map + newPos[this.#coordsToNumMap.get(coord)!] = scaler.transform( + vertex.position[coord], + ); + return { + ...vertex, + position: newPos, + }; + }) + ); + } + + #frame() { + if (!this.#keepRendering) { + return; + } + + if (this.#eventHandler.cameraChanged) { + this.#resourceKeeper.updateCameraView( + this.#eventHandler.cameraViewMatrix, + ); + this.#eventHandler.resetCameraChangedFlag(); + } + + this.#render(); + requestAnimationFrame(this.#bindedFrameFunction); + } + + #render() { + const emptySurfaceStack = this.#resourceKeeper.surfaceStack.length === 0; + const topology = this.#plotConfig.topology; + if (!emptySurfaceStack) { + const firstSurface = this.#resourceKeeper.surfaceStack[0]; + + if (topology === 'triangle' || topology === 'all') { + this.#drawObject( + firstSurface.vertexBuffer, + this.#resourceKeeper.bindgroup, + firstSurface.triangleIndexBuffer, + firstSurface.triangleIndexCount, + 'clear', + 'triangle', + ); + + for (const surface of this.#resourceKeeper.surfaceStack.slice(1)) { + this.#drawObject( + surface.vertexBuffer, + this.#resourceKeeper.bindgroup, + surface.triangleIndexBuffer, + surface.triangleIndexCount, + 'load', + 'triangle', + ); + } + } + + const lineFirstLoadOp = topology === 'line' ? 'clear' : 'load'; + + if (topology === 'line' || topology === 'all') { + this.#drawObject( + firstSurface.vertexBuffer, + this.#resourceKeeper.bindgroup, + firstSurface.lineIndexBuffer, + firstSurface.lineIndexCount, + lineFirstLoadOp, + 'line', + ); + + for (const surface of this.#resourceKeeper.surfaceStack.slice(1)) { + this.#drawObject( + surface.vertexBuffer, + this.#resourceKeeper.bindgroup, + surface.lineIndexBuffer, + surface.lineIndexCount, + 'load', + 'line', + ); + } + } + } + + const planes = this.#resourceKeeper.planes; + + const yPlane = this.#plotConfig.yZeroPlane; + const xPlane = this.#plotConfig.xZeroPlane; + const zPlane = this.#plotConfig.zZeroPlane; + const xLoadOp = emptySurfaceStack ? 'clear' : 'load'; + const zLoadOp = emptySurfaceStack && !xPlane ? 'clear' : 'load'; + const yLoadOp = emptySurfaceStack && !xPlane && !zPlane ? 'clear' : 'load'; + + if (xPlane) { + this.#drawObject( + planes.vertexBuffer, + planes.xZero.bindgroup, + planes.indexBuffer, + 6, + xLoadOp, + 'triangle', + ); + } + + if (zPlane) { + this.#drawObject( + planes.vertexBuffer, + planes.zZero.bindgroup, + planes.indexBuffer, + 6, + zLoadOp, + 'triangle', + ); + } + + if (yPlane) { + this.#drawObject( + planes.vertexBuffer, + planes.yZero.bindgroup, + planes.indexBuffer, + 6, + yLoadOp, + 'triangle', + ); + } + } + + #drawObject( + buffer: TgpuBuffer> & VertexFlag, + group: TgpuBindGroup, + indexBuffer: TgpuBuffer> & IndexFlag, + vertexCount: number, + loadOp: 'clear' | 'load', + topology: 'line' | 'triangle' = 'triangle', + ): void { + const resources = this.#resourceKeeper; + const pipeline = topology === 'triangle' + ? this.#triangleRenderPipeline + : this.#lineRenderPipeline; + + pipeline + .withColorAttachment({ + view: resources.depthAndMsaa.msaaTextureView, + resolveTarget: this.#context.getCurrentTexture().createView(), + clearValue: [0, 0, 0, 0], + loadOp: loadOp, + storeOp: 'store', + }) + .withDepthStencilAttachment({ + view: resources.depthAndMsaa.depthTextureView, + depthClearValue: 1, + depthLoadOp: loadOp, + depthStoreOp: 'store', + }) + .with(vertexLayout, buffer) + .with(layout, group) + .withIndexBuffer(indexBuffer) + .drawIndexed(vertexCount); + } + + #createRenderPipelines(): void { + this.#triangleRenderPipeline = this.#root['~unstable'] + .withVertex(vertexFn, vertexLayout.attrib) + .withFragment(fragmentFn, { + format: this.#presentationFormat, + blend: { + color: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + alpha: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + }, + }) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .withMultisample({ + count: 4, + }) + .withPrimitive({ + topology: 'triangle-list', + }) + .createPipeline(); + + this.#lineRenderPipeline = this.#root['~unstable'] + .withVertex(lineVertexFn, vertexLayout.attrib) + .withFragment(lineFragmentFn, { + format: this.#presentationFormat, + blend: { + color: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + alpha: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + }, + }) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .withMultisample({ + count: 4, + }) + .withPrimitive({ + topology: 'line-list', + }) + .createPipeline(); + } + + [Symbol.dispose]() { + this.#eventHandler.destroy(); + // surface stack will be handled by root + this.#root.destroy(); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/resource-keeper.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/resource-keeper.ts new file mode 100644 index 000000000..bc21c694b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/resource-keeper.ts @@ -0,0 +1,306 @@ +import type { + IndexFlag, + TgpuBindGroup, + TgpuBuffer, + TgpuRoot, + UniformFlag, + VertexFlag, +} from 'typegpu'; +import * as d from 'typegpu/data'; +import * as m from 'wgpu-matrix'; + +import type { CameraConfig, ScaleTransform } from './types.ts'; +import * as s from './structures.ts'; +import { layout, vertexLayout } from './layouts.ts'; +import { createTransformMatrix } from './utils.ts'; +import { GridSurface } from './grid.ts'; +import * as c from './constants.ts'; + +interface DepthAndMsaa { + depthTexture: GPUTexture; + depthTextureView: GPUTextureView; + msaaTexture: GPUTexture; + msaaTextureView: GPUTextureView; +} + +interface PlanesResources { + vertexBuffer: TgpuBuffer> & VertexFlag; + indexBuffer: TgpuBuffer> & IndexFlag; + xZero: { + transformUniform: TgpuBuffer & UniformFlag; + bindgroup: TgpuBindGroup<(typeof layout)['entries']>; + }; + yZero: { + transformUniform: TgpuBuffer & UniformFlag; + bindgroup: TgpuBindGroup<(typeof layout)['entries']>; + }; + zZero: { + transformUniform: TgpuBuffer & UniformFlag; + bindgroup: TgpuBindGroup<(typeof layout)['entries']>; + }; +} + +interface SurfaceResources { + vertexBuffer: TgpuBuffer> & VertexFlag; + triangleIndexBuffer: TgpuBuffer> & IndexFlag; + lineIndexBuffer: TgpuBuffer> & IndexFlag; + triangleIndexCount: number; + lineIndexCount: number; +} + +export class ResourceKeeper { + #root: TgpuRoot; + #canvas: HTMLCanvasElement; + #presentationFormat: GPUTextureFormat; + #cameraUniform: TgpuBuffer & UniformFlag; + #transformUniform: TgpuBuffer & UniformFlag; + #bindgroup: TgpuBindGroup<(typeof layout)['entries']>; + #planes: PlanesResources; + #depthAndMsaa: DepthAndMsaa; + #surfaceStack: SurfaceResources[] = []; + + constructor( + root: TgpuRoot, + canvas: HTMLCanvasElement, + presentationFormat: GPUTextureFormat, + ) { + this.#root = root; + this.#canvas = canvas; + this.#presentationFormat = presentationFormat; + + this.#cameraUniform = root.createBuffer(s.Camera).$usage('uniform'); + + this.#transformUniform = root.createBuffer(s.Transform) + .$usage('uniform'); + + this.#bindgroup = root.createBindGroup(layout, { + camera: this.#cameraUniform, + transform: this.#transformUniform, + }); + + this.#planes = this.#initPlanes(); + + this.#depthAndMsaa = this.#createDepthAndMsaaTextures( + canvas, + presentationFormat, + ); + } + + createSurfaceStackResources( + surfaces: { + vertices: d.Infer[]; + triangleIndices: number[]; + lineIndices: number[]; + }[], + ): void { + for (const { vertices, triangleIndices, lineIndices } of surfaces) { + const vertexBuffer = this.#root.createBuffer( + vertexLayout.schemaForCount(vertices.length), + vertices, + ).$usage( + 'vertex', + ); + const triangleIndexBuffer = this.#root.createBuffer( + d.arrayOf(d.u32, triangleIndices.length), + triangleIndices, + ).$usage( + 'index', + ); + + const lineIndexBuffer = this.#root.createBuffer( + d.arrayOf(d.u32, lineIndices.length), + lineIndices, + ).$usage( + 'index', + ); + + this.#surfaceStack.push({ + vertexBuffer, + triangleIndexBuffer, + triangleIndexCount: triangleIndices.length, + lineIndexBuffer, + lineIndexCount: lineIndices.length, + }); + } + } + + resetSurfaceStack(): void { + for (const surface of this.#surfaceStack) { + surface.vertexBuffer.destroy(); + surface.triangleIndexBuffer.destroy(); + surface.lineIndexBuffer.destroy(); + } + this.#surfaceStack = []; + } + + get surfaceStack(): SurfaceResources[] { + return this.#surfaceStack; + } + + get bindgroup(): TgpuBindGroup<(typeof layout)['entries']> { + return this.#bindgroup; + } + + get planes(): PlanesResources { + return this.#planes; + } + + get depthAndMsaa(): DepthAndMsaa { + return this.#depthAndMsaa; + } + + updateTransformUniform(scaleTransform: ScaleTransform): void { + this.#transformUniform.write( + createTransformMatrix(scaleTransform.offset, scaleTransform.scale), + ); + } + + updatePlanesTransformUniforms(scaleTransform: ScaleTransform): void { + const yZeroTransform = createTransformMatrix( + d.vec3f(0, scaleTransform.offset.y, 0), + scaleTransform.scale, + ); + + const xZeroMatrix = d.mat4x4f + .rotationZ(-Math.PI / 2) + .mul( + createTransformMatrix( + d.vec3f(0, scaleTransform.offset.x, 0), + scaleTransform.scale, + ).model, + ); + + const zZeroMatrix = d.mat4x4f + .rotationX(Math.PI / 2) + .mul( + createTransformMatrix( + d.vec3f(0, scaleTransform.offset.z, 0), + scaleTransform.scale, + ).model, + ); + + this.#planes.yZero.transformUniform.write(yZeroTransform); + this.#planes.xZero.transformUniform.write({ model: xZeroMatrix }); + this.#planes.zZero.transformUniform.write({ model: zZeroMatrix }); + } + + updateDepthAndMsaa() { + this.#depthAndMsaa = this.#createDepthAndMsaaTextures( + this.#canvas, + this.#presentationFormat, + ); + } + + updateCameraUniform(cameraConfig: CameraConfig, aspect: number): void { + const camera = { + view: m.mat4.lookAt( + cameraConfig.position, + cameraConfig.target, + cameraConfig.up, + d.mat4x4f(), + ), + projection: m.mat4.perspective( + cameraConfig.fov, + aspect, + cameraConfig.near, + cameraConfig.far, + d.mat4x4f(), + ), + }; + this.#cameraUniform.write(camera); + } + + updateCameraView(camereViewMatrix: d.m4x4f) { + this.#cameraUniform.writePartial({ view: camereViewMatrix }); + } + + #initPlanes(): PlanesResources { + const grid = new GridSurface(c.PLANE_GRID_CONFIG); + + const gridVertices = grid.getVertexBufferData(); + + const gridIndices = grid.getIndexBufferData(); + + const vertexBuffer = this.#root.createBuffer( + vertexLayout.schemaForCount(4), + gridVertices, + ).$usage('vertex'); + + const indexBuffer = this.#root.createBuffer( + d.arrayOf(d.u32, 6), + gridIndices, + ).$usage('index'); + + const xTransform = this.#root.createBuffer(s.Transform).$usage('uniform'); + + const xBindgroup = this.#root.createBindGroup(layout, { + camera: this.#cameraUniform, + transform: xTransform, + }); + + const yTransform = this.#root.createBuffer(s.Transform).$usage('uniform'); + + const yBindgroup = this.#root.createBindGroup(layout, { + camera: this.#cameraUniform, + transform: yTransform, + }); + + const zTransform = this.#root.createBuffer(s.Transform).$usage('uniform'); + + const zBindgroup = this.#root.createBindGroup(layout, { + camera: this.#cameraUniform, + transform: zTransform, + }); + + return { + vertexBuffer: vertexBuffer, + indexBuffer: indexBuffer, + + xZero: { + transformUniform: xTransform, + bindgroup: xBindgroup, + }, + yZero: { + transformUniform: yTransform, + bindgroup: yBindgroup, + }, + zZero: { + transformUniform: zTransform, + bindgroup: zBindgroup, + }, + }; + } + + #createDepthAndMsaaTextures( + canvas: HTMLCanvasElement, + presentationFormat: GPUTextureFormat, + ): DepthAndMsaa { + if (this.#depthAndMsaa?.depthTexture) { + this.#depthAndMsaa.depthTexture.destroy(); + } + if (this.#depthAndMsaa?.msaaTexture) { + this.#depthAndMsaa.msaaTexture.destroy(); + } + + const depthTexture = this.#root.device.createTexture({ + size: [canvas.width, canvas.height, 1], + format: 'depth24plus', + sampleCount: 4, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const msaaTexture = this.#root.device.createTexture({ + size: [canvas.width, canvas.height, 1], + format: presentationFormat, + sampleCount: 4, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + return { + depthTexture, + depthTextureView: depthTexture.createView(), + msaaTexture, + msaaTextureView: msaaTexture.createView(), + }; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/scalers.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/scalers.ts new file mode 100644 index 000000000..c64759bf2 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/scalers.ts @@ -0,0 +1,47 @@ +import * as c from './constants.ts'; +import type { IScaler } from './types.ts'; + +const MinMaxScaler: IScaler = { + type: 'affine', + fit(data: number[]): { offset: number; scale: number } { + const [min, max] = data.reduce( + (acc, val) => [Math.min(acc[0], val), Math.max(acc[1], val)], + [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + ); + const offset = -2 * min / (max - min + c.EPS) - 1; + const scale = 2 / (max - min + c.EPS); + return { offset, scale }; + }, +}; + +const SignPreservingScaler: IScaler = { + type: 'affine', + fit(data: number[]): { offset: number; scale: number } { + const absMax = data.reduce( + (acc, val) => Math.max(Math.abs(acc), Math.abs(val)), + 0, + ); + return { offset: 0, scale: 1 / (absMax + c.EPS) }; + }, +}; + +const IdentityScaler: IScaler = { + type: 'affine', + fit(data: number[]) { + return { scale: 1, offset: 0 }; + }, +}; + +const LogScaler: IScaler = { + type: 'non-affine', + transform(value: number): number { + return Math.log2(value); + }, +}; + +export const Scalers = { + MinMaxScaler, + IdentityScaler, + SignPreservingScaler, + LogScaler, +}; diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/shaders.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/shaders.ts new file mode 100644 index 000000000..35db90791 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/shaders.ts @@ -0,0 +1,45 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; + +import { layout } from './layouts.ts'; + +export const vertexFn = tgpu['~unstable'].vertexFn({ + in: { position: d.vec4f, color: d.vec4f }, + out: { pos: d.builtin.position, color: d.vec4f }, +})((input) => { + const pos = std.mul( + layout.$.camera.projection, + std.mul( + layout.$.camera.view, + std.mul(layout.$.transform.model, input.position), + ), + ); + return { pos, color: input.color }; +}); + +export const fragmentFn = tgpu['~unstable'].fragmentFn({ + in: { color: d.vec4f }, + out: d.vec4f, +})((input) => input.color); + +export const lineVertexFn = tgpu['~unstable'].vertexFn({ + in: { position: d.vec4f, edgeColor: d.vec4f }, + out: { pos: d.builtin.position, edgeColor: d.vec4f }, +})((input) => { + const pos = std.mul( + layout.$.camera.projection, + std.mul( + layout.$.camera.view, + std.mul(layout.$.transform.model, input.position), + ), + ); + return { pos: pos.add(d.vec4f(0, 0.001, 0, 0)), edgeColor: input.edgeColor }; +}); + +export const lineFragmentFn = tgpu['~unstable'].fragmentFn({ + in: { edgeColor: d.vec4f }, + out: d.vec4f, +})((input) => { + return input.edgeColor; +}); diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/structures.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/structures.ts new file mode 100644 index 000000000..076705000 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/structures.ts @@ -0,0 +1,16 @@ +import * as d from 'typegpu/data'; + +export const Vertex = d.struct({ + position: d.vec4f, + color: d.vec4f, + edgeColor: d.vec4f, +}); + +export const Camera = d.struct({ + view: d.mat4x4f, + projection: d.mat4x4f, +}); + +export const Transform = d.struct({ + model: d.mat4x4f, +}); diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/types.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/types.ts new file mode 100644 index 000000000..d5a55bf7d --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/types.ts @@ -0,0 +1,81 @@ +import type * as d from 'typegpu/data'; + +import type * as s from './structures.ts'; + +export interface IPlotter { + init: (context: GPUCanvasContext) => Promise; + updateCamera: (cameraConfig: CameraConfig) => void; + addPlots: (surfaces: ISurface[], options: PlotConfig) => void; + resetPlots: () => void; + startRenderLoop: () => void; + stopRenderLoop: () => void; +} + +export interface PlotConfig { + basePlanesTranslation: d.v3f; + basePlanesScale: d.v3f; + basePlotsTranslation: d.v3f; + basePlotsScale: d.v3f; + topology: 'triangle' | 'line' | 'all'; + xScaler: IScaler; + yScaler: IScaler; + zScaler: IScaler; + xZeroPlane: boolean; + yZeroPlane: boolean; + zZeroPlane: boolean; +} + +export interface ScaleTransform { + offset: d.v3f; + scale: d.v3f; +} + +export interface ISurface { + getVertexBufferData: () => d.Infer[]; + getIndexBufferData: () => number[]; +} + +export type IScaler = + | { + type: 'affine'; + fit: (data: number[]) => { scale: number; offset: number }; + } + | { + type: 'non-affine'; + transform: (value: number) => number; + }; + +export interface CameraConfig { + zoomable: boolean; + draggable: boolean; + position: d.v4f; + target: d.v3f; + up: d.v3f; + fov: number; + near: number; + far: number; + orbitSensitivity: number; + zoomSensitivity: number; + maxZoom: number; +} + +export interface GridConfig { + nx: number; + nz: number; + xRange: Range; + zRange: Range; + yCallback: (x: number, z: number) => number; + /** + * be aware that colorCallback is applied after scaling + */ + colorCallback: (y: number) => d.v4f; + /** + * be aware that edgeColorCallback is applied after scaling + */ + edgeColorCallback: (y: number) => d.v4f; +} + +export interface Range { + min: number; + max: number; +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/utils.ts b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/utils.ts new file mode 100644 index 000000000..bd9624693 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/heatmap/prism/src/utils.ts @@ -0,0 +1,29 @@ +import * as d from 'typegpu/data'; + +import type * as s from './structures.ts'; + +export function createTransformMatrix( + translation: d.v3f, + scale: d.v3f, +): d.Infer { + return { + model: d.mat4x4f.translation(translation).mul(d.mat4x4f.scaling(scale)), + }; +} + +export function createLineListFromTriangleList( + indexBufferData: number[], +): number[] { + const lineList = []; + for (let i = 0; i < indexBufferData.length; i += 3) { + lineList.push( + indexBufferData[i], + indexBufferData[i + 1], + indexBufferData[i + 1], + indexBufferData[i + 2], + indexBufferData[i + 2], + indexBufferData[i], + ); + } + return lineList; +} diff --git a/apps/typegpu-docs/src/examples/rendering/heatmap/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/heatmap/thumbnail.png new file mode 100644 index 000000000..c90b4e4ad Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/heatmap/thumbnail.png differ