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