diff --git a/apps/typegpu-docs/astro.config.mjs b/apps/typegpu-docs/astro.config.mjs
index 116f22fb6a..ec8e11a173 100644
--- a/apps/typegpu-docs/astro.config.mjs
+++ b/apps/typegpu-docs/astro.config.mjs
@@ -343,6 +343,10 @@ export default defineConfig({
]),
}),
react(),
- sitemap(),
+ sitemap({
+ // TODO(#2775): Remove this once the new homepage is live
+ // Match on the path so this keeps working regardless of deploy host/base.
+ filter: (page) => new URL(page).pathname.replace(/\/$/, '') !== '/TypeGPU/new',
+ }),
],
});
diff --git a/apps/typegpu-docs/package.json b/apps/typegpu-docs/package.json
index 09e7da1f77..a4395c3f56 100644
--- a/apps/typegpu-docs/package.json
+++ b/apps/typegpu-docs/package.json
@@ -74,6 +74,7 @@
},
"devDependencies": {
"@observablehq/plot": "^0.6.17",
+ "@shikijs/themes": "^4.0.0",
"@types/babel__standalone": "^7.1.9",
"@types/babel__template": "^7.4.4",
"@types/babel__traverse": "^7.20.7",
diff --git a/apps/typegpu-docs/public/assets/plum.glb b/apps/typegpu-docs/public/assets/plum.glb
new file mode 100644
index 0000000000..397661ca51
Binary files /dev/null and b/apps/typegpu-docs/public/assets/plum.glb differ
diff --git a/apps/typegpu-docs/src/assets/externalopen.svg b/apps/typegpu-docs/src/assets/externalopen.svg
new file mode 100644
index 0000000000..f7589a04f8
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/externalopen.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/assets/github-icon.svg b/apps/typegpu-docs/src/assets/github-icon.svg
index 440f2be1f6..23d6f878a3 100644
--- a/apps/typegpu-docs/src/assets/github-icon.svg
+++ b/apps/typegpu-docs/src/assets/github-icon.svg
@@ -1,5 +1,5 @@
-
\ No newline at end of file
+ fill="currentColor" />
+
diff --git a/apps/typegpu-docs/src/assets/hero/book-opened.svg b/apps/typegpu-docs/src/assets/hero/book-opened.svg
new file mode 100644
index 0000000000..644e649951
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/hero/book-opened.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/typegpu-docs/src/assets/hero/brackets.svg b/apps/typegpu-docs/src/assets/hero/brackets.svg
new file mode 100644
index 0000000000..e7868ca23c
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/hero/brackets.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/typegpu-docs/src/assets/hero/plus-circle.svg b/apps/typegpu-docs/src/assets/hero/plus-circle.svg
new file mode 100644
index 0000000000..eef4d648ac
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/hero/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/typegpu-docs/src/assets/moon.svg b/apps/typegpu-docs/src/assets/moon.svg
new file mode 100644
index 0000000000..a091833730
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/moon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/typegpu-docs/src/assets/sun.svg b/apps/typegpu-docs/src/assets/sun.svg
new file mode 100644
index 0000000000..ebdbb6e9a8
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/sun.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/assets/swm-logomark-top-right.svg b/apps/typegpu-docs/src/assets/swm-logomark-top-right.svg
new file mode 100644
index 0000000000..71317bf4aa
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/swm-logomark-top-right.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/assets/swm-logomark.svg b/apps/typegpu-docs/src/assets/swm-logomark.svg
index 2fc7e28cce..f5ee46cc29 100644
--- a/apps/typegpu-docs/src/assets/swm-logomark.svg
+++ b/apps/typegpu-docs/src/assets/swm-logomark.svg
@@ -2,20 +2,20 @@
+ fill="currentColor" />
+ fill="currentColor" />
+ fill="currentColor" />
+ fill="currentColor" />
-
\ No newline at end of file
+
diff --git a/apps/typegpu-docs/src/assets/youtube.svg b/apps/typegpu-docs/src/assets/youtube.svg
new file mode 100644
index 0000000000..f4e653d625
--- /dev/null
+++ b/apps/typegpu-docs/src/assets/youtube.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/typegpu-docs/src/components/CallToActionButton.astro b/apps/typegpu-docs/src/components/CallToActionButton.astro
new file mode 100644
index 0000000000..4d7fecc703
--- /dev/null
+++ b/apps/typegpu-docs/src/components/CallToActionButton.astro
@@ -0,0 +1,49 @@
+---
+interface Props {
+ title: string;
+ href: string;
+ size: 'lg' | 'base';
+ class?: string;
+}
+
+const {
+ title = 'Get started',
+ href = '/TypeGPU/getting-started',
+ size,
+ class: className,
+} = Astro.props;
+---
+
+
+ {title}
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/components/CodeButton.astro b/apps/typegpu-docs/src/components/CodeButton.astro
new file mode 100644
index 0000000000..8f3f4793e9
--- /dev/null
+++ b/apps/typegpu-docs/src/components/CodeButton.astro
@@ -0,0 +1,39 @@
+---
+interface Props {
+ title: string;
+ message: string;
+ icon: ((_props: astroHTML.JSX.SVGAttributes) => any) & ImageMetadata;
+}
+
+const { title, message, icon: Icon } = Astro.props as Props;
+---
+
+
+
+
+ {Icon && }
+
+
+
+ {title}
+
+
+
{/* Spacing */}
+
+
+ {message}
+
+
diff --git a/apps/typegpu-docs/src/components/CodePen.astro b/apps/typegpu-docs/src/components/CodePen.astro
new file mode 100644
index 0000000000..aabae38e88
--- /dev/null
+++ b/apps/typegpu-docs/src/components/CodePen.astro
@@ -0,0 +1,56 @@
+---
+import { Code } from 'astro:components';
+import catppuccinMocha from '@shikijs/themes/catppuccin-mocha';
+
+type SupportedLanguages =
+ | 'typescript'
+ | 'javascript'
+ | 'jsx'
+ | 'tsx'
+ | 'html'
+ | 'css'
+ | 'json'
+ | 'yaml'
+ | 'markdown'
+ | 'bash'
+ | 'shell';
+
+interface Props {
+ code: string;
+ language?: SupportedLanguages;
+}
+
+const { code, language = 'typescript' } = Astro.props as Props;
+
+const formattedCode = code.trim();
+---
+
+
+
diff --git a/apps/typegpu-docs/src/components/CodeShowcase.astro b/apps/typegpu-docs/src/components/CodeShowcase.astro
new file mode 100644
index 0000000000..37b829fe9d
--- /dev/null
+++ b/apps/typegpu-docs/src/components/CodeShowcase.astro
@@ -0,0 +1,229 @@
+---
+import CodeButton from './CodeButton.astro';
+import CodePen from './CodePen.astro';
+import Brackets from '../assets/hero/brackets.svg';
+import PlusCircle from '../assets/hero/plus-circle.svg';
+import BookOpened from '../assets/hero/book-opened.svg';
+
+const sectionId = 'interactive-code-section';
+---
+
+
+
+
+
+
+
+ d.arrayOf(d.arrayOf(d.f32, height), width);
+
+// ...then infer types from them
+type HeightMap = ReturnType;
+
+export async function generateHeightMap(
+ root: TgpuRoot,
+ opts: { width: number; height: number },
+): Promise & StorageFlag> {
+ const buffer = root
+ .createBuffer(HeightMap(opts.width, opts.height))
+ .$usage('storage');
+
+ const rawBuffer = root.unwrap(buffer); // => GPUBuffer
+
+ // Here we can do anything we would usually do with a
+ // WebGPU buffer, like populating it in a compute shader.
+ // 'rawBuffer' is the WebGPU resource that is backing the
+ // typed 'buffer' object, meaning any changes to it will
+ // be visible in both.
+
+ return buffer;
+}`}
+ language="typescript"
+ />
+ >> & StorageFlag
+
+function frame() {
+ requestAnimationFrame(frame);
+
+ // ERROR: Argument of type 'TgpuBuffer>>' is
+ // not assignable to parameter of type 'TgpuBuffer>>'
+ plot.array1d(root, terrainBuffer);
+
+ // SUCCESS!
+ plot.array2d(root, terrainBuffer);
+}`}
+ language="typescript"
+ />
+
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/components/GradientTitle.astro b/apps/typegpu-docs/src/components/GradientTitle.astro
new file mode 100644
index 0000000000..9d89b816be
--- /dev/null
+++ b/apps/typegpu-docs/src/components/GradientTitle.astro
@@ -0,0 +1,36 @@
+---
+interface Props {
+ title: string;
+}
+
+const { title } = Astro.props;
+---
+
+
+ {title}
+
+
+
diff --git a/apps/typegpu-docs/src/components/SecondaryButton.astro b/apps/typegpu-docs/src/components/SecondaryButton.astro
new file mode 100644
index 0000000000..433326475b
--- /dev/null
+++ b/apps/typegpu-docs/src/components/SecondaryButton.astro
@@ -0,0 +1,54 @@
+---
+interface Props {
+ title: string;
+ href: string;
+ size: 'lg' | 'base';
+ arrow?: boolean;
+ class?: string;
+}
+
+const { title, href, size, class: className, arrow = false } = Astro.props;
+---
+
+
+
+ {title}
+ {
+ arrow && (
+
+
+
+
+ )
+ }
+
diff --git a/apps/typegpu-docs/src/components/ThemeSwitcher.astro b/apps/typegpu-docs/src/components/ThemeSwitcher.astro
new file mode 100644
index 0000000000..4c49d289e3
--- /dev/null
+++ b/apps/typegpu-docs/src/components/ThemeSwitcher.astro
@@ -0,0 +1,87 @@
+---
+import LightModeIconSvg from '../assets/sun.svg';
+import DarkModeIconSvg from '../assets/moon.svg';
+---
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/components/Videos.astro b/apps/typegpu-docs/src/components/Videos.astro
new file mode 100644
index 0000000000..3acd452755
--- /dev/null
+++ b/apps/typegpu-docs/src/components/Videos.astro
@@ -0,0 +1,67 @@
+---
+import YoutubeSvg from '../assets/youtube.svg';
+
+const videos = [
+ {
+ title: 'Why Iwo Plaza Created TypeGPU | Syntax.fm',
+ src: 'https://www.youtube.com/embed/ZxPaKfRlrOE?si=Zjf0Cj8_WWDspLoQ',
+ className: 'md:col-span-2',
+ iframeClassName: 'h-[315px] md:h-[500px]',
+ },
+ {
+ title: 'Live-Coding a Liquid Glass Effect with TypeGPU',
+ src: 'https://www.youtube.com/embed/5PREvbHirEY?si=N2Cs3xn9IG8_Wh9g',
+ },
+ {
+ title: 'Iwo Plaza – Write TypeGPU, Use Anywhere (...)',
+ src: 'https://www.youtube.com/embed/GOjrDiPANYU?si=iTnPp2StLd7HRnU-',
+ },
+ {
+ title: 'Iwo Plaza – Your GPU is a JavaScript runtime* (TypeGPU deep-dive)',
+ src: 'https://www.youtube.com/embed/pBRLqJaG4kk?si=-xaSG_MET9-Q1ZfK',
+ },
+ {
+ title: 'Simplifying Compute Shaders with TypeGPU: GPU Worklets & More',
+ src: 'https://www.youtube.com/embed/QrHZXiXxu9A?si=CSaZznbx0hf3PlQj',
+ },
+];
+---
+
+
+ {
+ videos.map((video) => (
+
+ ))
+ }
+
diff --git a/apps/typegpu-docs/src/components/WebGPUErrorBoundary.tsx b/apps/typegpu-docs/src/components/WebGPUErrorBoundary.tsx
new file mode 100644
index 0000000000..4b9d184852
--- /dev/null
+++ b/apps/typegpu-docs/src/components/WebGPUErrorBoundary.tsx
@@ -0,0 +1,34 @@
+import { ClientOnly } from '@typegpu/react';
+import * as React from 'react';
+
+export interface WebGPUErrorBoundaryProps {
+ children: React.ReactNode;
+ fallback: React.ReactNode;
+}
+
+export class WebGPUErrorBoundary extends React.Component<
+ WebGPUErrorBoundaryProps,
+ { hasError: boolean }
+> {
+ constructor(props: WebGPUErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(_error: unknown) {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: unknown, _info: unknown) {
+ console.log(error);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback;
+ }
+
+ return {this.props.children} ;
+ }
+}
diff --git a/apps/typegpu-docs/src/components/hero-effect/HeroEffect.tsx b/apps/typegpu-docs/src/components/hero-effect/HeroEffect.tsx
new file mode 100644
index 0000000000..e58cf4cf9c
--- /dev/null
+++ b/apps/typegpu-docs/src/components/hero-effect/HeroEffect.tsx
@@ -0,0 +1,53 @@
+import { useEffect, useState } from 'react';
+import { initHeroEffect } from './hero-effect.ts';
+import { useConfigureContext, useRoot, useRootOrError } from '@typegpu/react';
+
+function HeroEffectWebGPU() {
+ const root = useRoot();
+ const { ref, ctxRef } = useConfigureContext({ alphaMode: 'premultiplied' });
+ const [isActive, setIsActive] = useState(false);
+
+ useEffect(() => {
+ const ctx = ctxRef.current;
+ if (!ctx) return;
+
+ let cancelled = false;
+ let onCleanup: (() => void) | undefined;
+ void (async () => {
+ const result = await initHeroEffect({ root, context: ctx });
+ onCleanup = () => result.onCleanup();
+ if (cancelled) {
+ onCleanup();
+ return;
+ }
+
+ setIsActive(true);
+ })();
+
+ return () => {
+ cancelled = true;
+ onCleanup?.();
+ onCleanup = undefined;
+ };
+ }, [root]);
+
+ return (
+
+
+
+ );
+}
+
+export function HeroEffect() {
+ const result = useRootOrError();
+
+ if (result.status === 'rejected') {
+ // Fallback
+ return null;
+ }
+
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/hero-effect/fluid-sim.ts b/apps/typegpu-docs/src/components/hero-effect/fluid-sim.ts
new file mode 100644
index 0000000000..f65dd20806
--- /dev/null
+++ b/apps/typegpu-docs/src/components/hero-effect/fluid-sim.ts
@@ -0,0 +1,624 @@
+import type { TgpuRoot } from 'typegpu';
+import tgpu, { d, std } from 'typegpu';
+
+export type SimulationParams = {
+ dt: number;
+ viscosity: number;
+ jacobiIter: number;
+ paused: boolean;
+};
+
+export type BrushState = {
+ pos: [number, number];
+ delta: [number, number];
+};
+
+export const N = 2048;
+export const SIM_N = N / 4;
+export const [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y] = [16, 16];
+export const FORCE_SCALE = 1;
+export const RADIUS = SIM_N / 16;
+export const INK_AMOUNT = 0.02;
+
+export const params: SimulationParams = {
+ dt: 0.3,
+ viscosity: 0.000001,
+ jacobiIter: 10,
+ paused: false,
+};
+
+export const BrushParams = d.struct({
+ pos: d.vec2i,
+ delta: d.vec2f,
+ radius: d.f32,
+ forceScale: d.f32,
+ inkAmount: d.f32,
+});
+
+export const ShaderParams = d.struct({
+ dt: d.f32,
+ viscosity: d.f32,
+});
+
+export const renderFluidSimLayout = tgpu.bindGroupLayout({
+ inkTexture: { texture: d.texture2d() },
+});
+
+function getNeighbors(coords: d.v2i, bounds: d.v2i): d.v2i[] {
+ 'use gpu';
+ const adjacentOffsets = [d.vec2i(-1, 0), d.vec2i(0, -1), d.vec2i(1, 0), d.vec2i(0, 1)];
+ for (const i of tgpu.unroll(std.range(4))) {
+ adjacentOffsets[i] = std.clamp(coords + adjacentOffsets[i], d.vec2i(), bounds - d.vec2i(1));
+ }
+ return adjacentOffsets;
+}
+
+export const brushLayout = tgpu.bindGroupLayout({
+ brushParams: { uniform: BrushParams },
+ forceDst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+ inkDst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+});
+
+export const brushFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ 'use gpu';
+ const pixelPos = input.gid.xy;
+ const brushSettings = brushLayout.$.brushParams;
+
+ let forceVec = d.vec2f(0.0);
+ let inkAmount = d.f32(0.0);
+
+ const deltaX = d.f32(pixelPos.x) - d.f32(brushSettings.pos.x);
+ const deltaY = d.f32(pixelPos.y) - d.f32(brushSettings.pos.y);
+ const distSquared = deltaX * deltaX + deltaY * deltaY;
+ const radiusSquared = brushSettings.radius * brushSettings.radius;
+
+ if (distSquared < radiusSquared) {
+ const brushWeight = std.exp(-distSquared / radiusSquared);
+ forceVec = brushSettings.forceScale * brushWeight * brushSettings.delta;
+ inkAmount = brushSettings.inkAmount * brushWeight;
+ }
+
+ std.textureStore(brushLayout.$.forceDst, pixelPos, d.vec4f(forceVec, 0.0, 1.0));
+ std.textureStore(brushLayout.$.inkDst, pixelPos, d.vec4f(inkAmount, 0.0, 0.0, 1.0));
+});
+
+export const addForcesLayout = tgpu.bindGroupLayout({
+ src: { texture: d.texture2d(d.f32) },
+ dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+ force: { texture: d.texture2d(d.f32) },
+ simParams: { uniform: ShaderParams },
+});
+
+export const addForcesFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ 'use gpu';
+ const pixelPos = input.gid.xy;
+ const currentVel = std.textureLoad(addForcesLayout.$.src, pixelPos, 0).xy;
+ const forceVec = std.textureLoad(addForcesLayout.$.force, pixelPos, 0).xy;
+ const timeStep = addForcesLayout.$.simParams.dt;
+ const newVel = currentVel + timeStep * forceVec;
+ std.textureStore(addForcesLayout.$.dst, pixelPos, d.vec4f(newVel, 0, 1));
+});
+
+export const advectLayout = tgpu.bindGroupLayout({
+ src: { texture: d.texture2d(d.f32) },
+ dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+ simParams: { uniform: ShaderParams },
+ linSampler: { sampler: 'filtering' },
+});
+
+export const advectFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ 'use gpu';
+ const texSize = std.textureDimensions(advectLayout.$.src);
+ const pixelPos = input.gid.xy;
+
+ if (
+ pixelPos.x >= texSize.x - 1 ||
+ pixelPos.y >= texSize.y - 1 ||
+ pixelPos.x <= 0 ||
+ pixelPos.y <= 0
+ ) {
+ std.textureStore(advectLayout.$.dst, pixelPos, d.vec4f(0, 0, 0, 1));
+ return;
+ }
+
+ const velocity = std.textureLoad(advectLayout.$.src, pixelPos, 0);
+ const timeStep = advectLayout.$.simParams.dt;
+ const prevPos = d.vec2f(pixelPos) - timeStep * velocity.xy;
+ const clampedPos = std.clamp(prevPos, d.vec2f(-0.5), d.vec2f(texSize.xy) - 0.5);
+ const normalizedPos = std.div(clampedPos + 0.5, d.vec2f(texSize.xy));
+
+ const prevVelocity = std.textureSampleLevel(
+ advectLayout.$.src,
+ advectLayout.$.linSampler,
+ normalizedPos,
+ 0,
+ );
+
+ // Slowing the ink down synthetically
+ const slowedVelocity = prevVelocity * 0.999;
+ std.textureStore(advectLayout.$.dst, pixelPos, slowedVelocity);
+});
+
+export const diffusionLayout = tgpu.bindGroupLayout({
+ in: { texture: d.texture2d(d.f32) },
+ out: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+ simParams: { uniform: ShaderParams },
+});
+
+export const diffusionFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ 'use gpu';
+ const pixelPos = d.vec2i(input.gid.xy);
+ const texSize = d.vec2i(std.textureDimensions(diffusionLayout.$.in));
+ const centerVal = std.textureLoad(diffusionLayout.$.in, pixelPos, 0);
+
+ const neighbors = getNeighbors(pixelPos, texSize);
+
+ const leftVal = std.textureLoad(diffusionLayout.$.in, neighbors[0], 0);
+ const upVal = std.textureLoad(diffusionLayout.$.in, neighbors[1], 0);
+ const rightVal = std.textureLoad(diffusionLayout.$.in, neighbors[2], 0);
+ const downVal = std.textureLoad(diffusionLayout.$.in, neighbors[3], 0);
+
+ const timeStep = diffusionLayout.$.simParams.dt;
+ const viscosity = diffusionLayout.$.simParams.viscosity;
+
+ const diffuseRate = viscosity * timeStep;
+ const blendFactor = 1.0 / (4.0 + diffuseRate);
+ const diffusedVal =
+ d.vec4f(blendFactor) *
+ (leftVal + rightVal + (upVal + downVal) + d.f32(diffuseRate) * centerVal);
+
+ std.textureStore(diffusionLayout.$.out, pixelPos, diffusedVal);
+});
+
+export const divergenceLayout = tgpu.bindGroupLayout({
+ vel: { texture: d.texture2d(d.f32) },
+ div: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+});
+
+export const divergenceFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ const pixelPos = d.vec2i(input.gid.xy);
+ const texSize = d.vec2i(std.textureDimensions(divergenceLayout.$.vel));
+
+ const neighbors = getNeighbors(pixelPos, texSize);
+
+ const leftVel = std.textureLoad(divergenceLayout.$.vel, neighbors[0], 0);
+ const upVel = std.textureLoad(divergenceLayout.$.vel, neighbors[1], 0);
+ const rightVel = std.textureLoad(divergenceLayout.$.vel, neighbors[2], 0);
+ const downVel = std.textureLoad(divergenceLayout.$.vel, neighbors[3], 0);
+
+ const divergence = 0.5 * (rightVel.x - leftVel.x + (downVel.y - upVel.y));
+ std.textureStore(divergenceLayout.$.div, pixelPos, d.vec4f(divergence, 0, 0, 1));
+});
+
+export const pressureLayout = tgpu.bindGroupLayout({
+ x: { texture: d.texture2d(d.f32) },
+ b: { texture: d.texture2d(d.f32) },
+ out: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+});
+
+export const pressureFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ const pixelPos = d.vec2i(input.gid.xy);
+ const texSize = d.vec2i(std.textureDimensions(pressureLayout.$.x));
+
+ const neighbors = getNeighbors(pixelPos, texSize);
+
+ const leftPressure = std.textureLoad(pressureLayout.$.x, neighbors[0], 0);
+ const upPressure = std.textureLoad(pressureLayout.$.x, neighbors[1], 0);
+ const rightPressure = std.textureLoad(pressureLayout.$.x, neighbors[2], 0);
+ const downPressure = std.textureLoad(pressureLayout.$.x, neighbors[3], 0);
+
+ const divergence = std.textureLoad(pressureLayout.$.b, pixelPos, 0).x;
+ const newPressure =
+ d.f32(0.25) * (leftPressure.x + rightPressure.x + upPressure.x + downPressure.x - divergence);
+ std.textureStore(pressureLayout.$.out, pixelPos, d.vec4f(newPressure, 0, 0, 1));
+});
+
+export const projectLayout = tgpu.bindGroupLayout({
+ vel: { texture: d.texture2d(d.f32) },
+ p: { texture: d.texture2d(d.f32) },
+ out: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+});
+
+export const projectFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ 'use gpu';
+ const pixelPos = d.vec2i(input.gid.xy);
+ const texSize = d.vec2i(std.textureDimensions(projectLayout.$.vel));
+ const velocity = std.textureLoad(projectLayout.$.vel, pixelPos, 0);
+
+ const neighbors = getNeighbors(pixelPos, texSize);
+
+ const leftPressure = std.textureLoad(projectLayout.$.p, neighbors[0], 0);
+ const upPressure = std.textureLoad(projectLayout.$.p, neighbors[1], 0);
+ const rightPressure = std.textureLoad(projectLayout.$.p, neighbors[2], 0);
+ const downPressure = std.textureLoad(projectLayout.$.p, neighbors[3], 0);
+
+ const pressureGrad = d.vec2f(
+ 0.5 * (rightPressure.x - leftPressure.x),
+ 0.5 * (downPressure.x - upPressure.x),
+ );
+ const projectedVel = velocity.xy - pressureGrad;
+ std.textureStore(projectLayout.$.out, pixelPos, d.vec4f(projectedVel, 0, 1));
+});
+
+export const advectInkLayout = tgpu.bindGroupLayout({
+ vel: { texture: d.texture2d(d.f32) },
+ src: { texture: d.texture2d(d.f32) },
+ dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+ simParams: { uniform: ShaderParams },
+ linSampler: { sampler: 'filtering' },
+});
+
+export const advectInkFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ 'use gpu';
+ const texSize = std.textureDimensions(advectInkLayout.$.src);
+ const pixelPos = input.gid.xy;
+
+ const velocity = std.textureLoad(advectInkLayout.$.vel, pixelPos, 0).xy;
+ const timeStep = advectInkLayout.$.simParams.dt;
+ const prevPos = d.vec2f(pixelPos) - timeStep * velocity;
+ const clampedPos = std.clamp(prevPos, d.vec2f(-0.5), d.vec2f(texSize.xy) - d.vec2f(0.5));
+ const normalizedPos = (clampedPos + d.vec2f(0.5)) / d.vec2f(texSize.xy);
+
+ const inkVal = std.textureSampleLevel(
+ advectInkLayout.$.src,
+ advectInkLayout.$.linSampler,
+ normalizedPos,
+ 0,
+ );
+ // Removing ink after a while
+ const decayedInk = inkVal * 0.99;
+ std.textureStore(advectInkLayout.$.dst, pixelPos, decayedInk);
+});
+
+export const addInkLayout = tgpu.bindGroupLayout({
+ src: { texture: d.texture2d(d.f32) },
+ dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+ add: { texture: d.texture2d(d.f32) },
+});
+
+export const addInkFn = tgpu.computeFn({
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y],
+ in: { gid: d.builtin.globalInvocationId },
+})((input) => {
+ const pixelPos = input.gid.xy;
+ const addVal = std.textureLoad(addInkLayout.$.add, pixelPos, 0).x;
+ const srcVal = std.textureLoad(addInkLayout.$.src, pixelPos, 0).x;
+ std.textureStore(addInkLayout.$.dst, pixelPos, d.vec4f(addVal + srcVal, 0, 0, 1));
+});
+
+class DoubleBuffer {
+ buffers: [T, T];
+ index: number;
+ constructor(bufferA: T, bufferB: T, initialIndex = 0) {
+ this.buffers = [bufferA, bufferB];
+ this.index = initialIndex;
+ }
+
+ get current(): T {
+ return this.buffers[this.index];
+ }
+ get currentIndex(): number {
+ return this.index;
+ }
+
+ swap(): void {
+ this.index ^= 1;
+ }
+ setCurrent(index: number): void {
+ this.index = index;
+ }
+}
+
+export function createFluidSim(root: TgpuRoot, canvas: HTMLCanvasElement) {
+ // Helpers
+ function createField(name: string) {
+ return root
+ .createTexture({ size: [SIM_N, SIM_N], format: 'rgba16float' })
+ .$usage('storage', 'sampled')
+ .$name(name);
+ }
+
+ function toGrid(x: number, y: number): [number, number] {
+ const gx = Math.floor((x / canvas.width) * SIM_N);
+ const gy = Math.floor(((canvas.height - y) / canvas.height) * SIM_N);
+ return [gx, gy];
+ }
+
+ // Buffers and brush state
+ const simParamBuffer = root
+ .createBuffer(ShaderParams, {
+ dt: params.dt,
+ viscosity: params.viscosity,
+ })
+ .$usage('uniform');
+
+ const brushParamBuffer = root
+ .createBuffer(BrushParams, {
+ pos: d.vec2i(0, 0),
+ delta: d.vec2f(0, 0),
+ radius: RADIUS,
+ forceScale: FORCE_SCALE,
+ inkAmount: INK_AMOUNT,
+ })
+ .$usage('uniform');
+
+ const brushState: BrushState = {
+ pos: [0, 0],
+ delta: [0, 0],
+ };
+
+ // Create simulation textures
+ const velTex = [createField('velocity0'), createField('velocity1')];
+ const inkTex = [createField('density0'), createField('density1')];
+ const pressureTex = [createField('pressure0'), createField('pressure1')];
+
+ const newInkTex = createField('addedInk');
+ const forceTex = createField('force');
+ const divergenceTex = createField('divergence');
+
+ const linSampler = root.createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+ });
+
+ // Create compute pipelines
+ const brushPipeline = root.createComputePipeline({ compute: brushFn });
+ const addForcePipeline = root.createComputePipeline({ compute: addForcesFn });
+ const advectPipeline = root.createComputePipeline({ compute: advectFn });
+ const diffusionPipeline = root.createComputePipeline({ compute: diffusionFn });
+ const divergencePipeline = root.createComputePipeline({ compute: divergenceFn });
+ const pressurePipeline = root.createComputePipeline({ compute: pressureFn });
+ const projectPipeline = root.createComputePipeline({ compute: projectFn });
+ const advectInkPipeline = root.createComputePipeline({ compute: advectInkFn });
+ const addInkPipeline = root.createComputePipeline({ compute: addInkFn });
+
+ // Setup simulation buffers
+ const velBuffer = new DoubleBuffer(velTex[0], velTex[1]);
+ const inkBuffer = new DoubleBuffer(inkTex[0], inkTex[1]);
+ const pressureBuffer = new DoubleBuffer(pressureTex[0], pressureTex[1]);
+
+ const dispatchX = Math.ceil(SIM_N / WORKGROUP_SIZE_X);
+ const dispatchY = Math.ceil(SIM_N / WORKGROUP_SIZE_Y);
+
+ // Create bind groups
+ const brushBindGroup = root.createBindGroup(brushLayout, {
+ brushParams: brushParamBuffer,
+ forceDst: forceTex.createView(d.textureStorage2d('rgba16float', 'write-only')),
+ inkDst: newInkTex.createView(d.textureStorage2d('rgba16float', 'write-only')),
+ });
+
+ const addInkBindGroups = [0, 1].map((i) => {
+ const srcIdx = i;
+ const dstIdx = 1 - i;
+ return root.createBindGroup(addInkLayout, {
+ src: inkTex[srcIdx].createView(d.texture2d(d.f32)),
+ add: newInkTex.createView(d.texture2d(d.f32)),
+ dst: inkTex[dstIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ });
+ });
+
+ const addForceBindGroups = [0, 1].map((i) => {
+ const srcIdx = i;
+ const dstIdx = 1 - i;
+ return root.createBindGroup(addForcesLayout, {
+ src: velTex[srcIdx].createView(d.texture2d(d.f32)),
+ force: forceTex.createView(d.texture2d(d.f32)),
+ dst: velTex[dstIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ simParams: simParamBuffer,
+ });
+ });
+
+ const advectBindGroups = [0, 1].map((i) => {
+ const srcIdx = 1 - i;
+ const dstIdx = i;
+ return root.createBindGroup(advectLayout, {
+ src: velTex[srcIdx].createView(d.texture2d(d.f32)),
+ dst: velTex[dstIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ simParams: simParamBuffer,
+ linSampler,
+ });
+ });
+
+ const diffusionBindGroups = [0, 1].map((i) => {
+ const srcIdx = i;
+ const dstIdx = 1 - i;
+ return root.createBindGroup(diffusionLayout, {
+ in: velTex[srcIdx].createView(d.texture2d(d.f32)),
+ out: velTex[dstIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ simParams: simParamBuffer,
+ });
+ });
+
+ const divergenceBindGroups = [0, 1].map((i) => {
+ const srcIdx = i;
+ return root.createBindGroup(divergenceLayout, {
+ vel: velTex[srcIdx].createView(d.texture2d(d.f32)),
+ div: divergenceTex.createView(d.textureStorage2d('rgba16float', 'write-only')),
+ });
+ });
+
+ const pressureBindGroups = [0, 1].map((i) => {
+ const srcIdx = i;
+ const dstIdx = 1 - i;
+ return root.createBindGroup(pressureLayout, {
+ x: pressureTex[srcIdx].createView(d.texture2d(d.f32)),
+ b: divergenceTex.createView(d.texture2d(d.f32)),
+ out: pressureTex[dstIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ });
+ });
+
+ const projectBindGroups = [0, 1].map((velIdx) =>
+ [0, 1].map((pIdx) => {
+ const srcVelIdx = velIdx;
+ const dstVelIdx = 1 - velIdx;
+ const srcPIdx = pIdx;
+ return root.createBindGroup(projectLayout, {
+ vel: velTex[srcVelIdx].createView(d.texture2d(d.f32)),
+ p: pressureTex[srcPIdx].createView(d.texture2d(d.f32)),
+ out: velTex[dstVelIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ });
+ }),
+ );
+
+ const advectInkBindGroups = [0, 1].map((velIdx) =>
+ [0, 1].map((inkIdx) => {
+ const srcVelIdx = velIdx;
+ const srcInkIdx = inkIdx;
+ const dstInkIdx = 1 - inkIdx;
+ return root.createBindGroup(advectInkLayout, {
+ vel: velTex[srcVelIdx].createView(d.texture2d(d.f32)),
+ src: inkTex[srcInkIdx].createView(d.texture2d(d.f32)),
+ dst: inkTex[dstInkIdx].createView(d.textureStorage2d('rgba16float', 'write-only')),
+ simParams: simParamBuffer,
+ linSampler,
+ });
+ }),
+ );
+
+ const onMouseDown = (e: MouseEvent) => {
+ const x = e.offsetX * devicePixelRatio;
+ const y = e.offsetY * devicePixelRatio;
+
+ brushState.pos = toGrid(x, y);
+ brushState.delta = [0, 0];
+ };
+ canvas.addEventListener('mousedown', onMouseDown);
+
+ const onTouchStart = (e: TouchEvent) => {
+ e.preventDefault();
+ const touch = e.touches[0];
+ const rect = canvas.getBoundingClientRect();
+ const x = (touch.clientX - rect.left) * devicePixelRatio;
+ const y = (touch.clientY - rect.top) * devicePixelRatio;
+ brushState.pos = toGrid(x, y);
+ brushState.delta = [0, 0];
+ };
+ canvas.addEventListener('touchstart', onTouchStart, { passive: false });
+
+ const onMouseMove = (e: MouseEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = (e.clientX - rect.left) * devicePixelRatio;
+ const y = (e.clientY - rect.top) * devicePixelRatio;
+ const [newX, newY] = toGrid(x, y);
+ brushState.delta = [newX - brushState.pos[0], newY - brushState.pos[1]];
+ brushState.pos = [newX, newY];
+ };
+ window.addEventListener('mousemove', onMouseMove);
+
+ const onTouchMove = (e: TouchEvent) => {
+ e.preventDefault();
+ const touch = e.touches[0];
+ const rect = canvas.getBoundingClientRect();
+ const x = (touch.clientX - rect.left) * devicePixelRatio;
+ const y = (touch.clientY - rect.top) * devicePixelRatio;
+ const [newX, newY] = toGrid(x, y);
+ brushState.delta = [newX - brushState.pos[0], newY - brushState.pos[1]];
+ brushState.pos = [newX, newY];
+ };
+ canvas.addEventListener('touchmove', onTouchMove, { passive: false });
+
+ const renderBindGroups = [0, 1].map((index) => {
+ return root.createBindGroup(renderFluidSimLayout, {
+ inkTexture: inkBuffer.buffers[index],
+ });
+ });
+
+ return {
+ update() {
+ brushParamBuffer.patch({
+ pos: d.vec2i(...brushState.pos),
+ delta: d.vec2f(...brushState.delta),
+ });
+
+ brushPipeline.with(brushBindGroup).dispatchWorkgroups(dispatchX, dispatchY);
+
+ addInkPipeline
+ .with(addInkBindGroups[inkBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+ inkBuffer.swap();
+
+ addForcePipeline
+ .with(addForceBindGroups[velBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+
+ advectPipeline
+ .with(advectBindGroups[velBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+
+ for (let i = 0; i < params.jacobiIter; i++) {
+ diffusionPipeline
+ .with(diffusionBindGroups[velBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+ velBuffer.swap();
+ }
+
+ divergencePipeline
+ .with(divergenceBindGroups[velBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+
+ pressureBuffer.setCurrent(0);
+ for (let i = 0; i < params.jacobiIter; i++) {
+ pressurePipeline
+ .with(pressureBindGroups[pressureBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+ pressureBuffer.swap();
+ }
+
+ projectPipeline
+ .with(projectBindGroups[velBuffer.currentIndex][pressureBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+ velBuffer.swap();
+
+ advectInkPipeline
+ .with(advectInkBindGroups[velBuffer.currentIndex][inkBuffer.currentIndex])
+ .dispatchWorkgroups(dispatchX, dispatchY);
+ inkBuffer.swap();
+ },
+ get renderBindGroup() {
+ return renderBindGroups[inkBuffer.currentIndex];
+ },
+ destroy() {
+ canvas.removeEventListener('mousedown', onMouseDown);
+ canvas.removeEventListener('touchstart', onTouchStart);
+ canvas.removeEventListener('touchmove', onTouchMove);
+ window.removeEventListener('mousemove', onMouseMove);
+
+ for (const tex of [
+ ...velTex,
+ ...inkTex,
+ ...pressureTex,
+ newInkTex,
+ forceTex,
+ divergenceTex,
+ ]) {
+ tex.destroy();
+ }
+ simParamBuffer.destroy();
+ brushParamBuffer.destroy();
+ },
+ };
+}
diff --git a/apps/typegpu-docs/src/components/hero-effect/hero-effect.ts b/apps/typegpu-docs/src/components/hero-effect/hero-effect.ts
new file mode 100644
index 0000000000..aa3309b33e
--- /dev/null
+++ b/apps/typegpu-docs/src/components/hero-effect/hero-effect.ts
@@ -0,0 +1,237 @@
+import tgpu, { d, std, type TgpuRoot } from 'typegpu';
+import { mat4 } from 'wgpu-matrix';
+import { loadModel, modelVertexLayout } from './load-model.ts';
+import { fullScreenTriangle } from 'typegpu/common';
+import { postProcessLayout, ScreenTextures } from './screen-textures.ts';
+import { createFluidSim, renderFluidSimLayout, SIM_N } from './fluid-sim.ts';
+
+interface HeroEffectOptions {
+ root: TgpuRoot;
+ context: GPUCanvasContext;
+}
+
+const Uniforms = d.struct({
+ viewProjection: d.mat4x4f,
+ modelMatrix: d.mat4x4f,
+});
+
+export async function initHeroEffect(options: HeroEffectOptions) {
+ const { root, context } = options;
+ const canvas = context.canvas as HTMLCanvasElement;
+ const model = await loadModel(root);
+ const fluidSim = createFluidSim(root, canvas);
+ const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
+
+ const resolution = [canvas.width, canvas.height] as [number, number];
+ const screenTextures = new ScreenTextures(root, resolution);
+
+ function onResize(width: number, height: number) {
+ resolution[0] = width;
+ resolution[1] = height;
+ screenTextures.resolution = resolution;
+ }
+
+ const uniforms = root.createUniform(Uniforms);
+ const fsampler = root.createSampler({
+ minFilter: 'linear',
+ magFilter: 'linear',
+ addressModeU: 'repeat',
+ addressModeV: 'repeat',
+ });
+
+ const vertexFn = tgpu.vertexFn({
+ in: { pos: d.vec3f, normal: d.vec3f, vid: d.builtin.vertexIndex },
+ out: { localPos: d.vec3f, position: d.builtin.position, normal: d.vec3f },
+ })((input) => {
+ 'use gpu';
+ const position = uniforms.$.viewProjection * uniforms.$.modelMatrix * d.vec4f(input.pos, 1);
+
+ return {
+ position,
+ localPos: input.pos,
+ normal: input.normal,
+ };
+ });
+
+ const fragmentFn = tgpu.fragmentFn({
+ in: { localPos: d.vec3f, normal: d.vec3f },
+ out: d.vec4f,
+ })((input) => {
+ 'use gpu';
+ const negLight = std.saturate(d.vec3f(0.2, 1, 0.1));
+ const att = std.dot(input.normal, negLight) * 0.5;
+ const ambient = d.vec3f(0.1, 0.1, 0.15);
+ const diffuse = d.vec3f(0.8, 0.6, 0.9);
+ return d.vec4f(std.saturate(diffuse * att + ambient), 1);
+ });
+
+ const renderPipeline = root.createRenderPipeline({
+ attribs: modelVertexLayout.attrib,
+ vertex: vertexFn,
+ fragment: fragmentFn,
+ targets: { format: presentationFormat },
+ depthStencil: {
+ format: 'depth24plus',
+ depthWriteEnabled: true,
+ depthCompare: 'less',
+ },
+ });
+
+ const CirclePattern = d.struct({
+ color: d.vec4f,
+ dist: d.f32,
+ });
+
+ const circlePattern = (rot: d.m2x2f, irot: d.m2x2f, uv: d.v2f, scale: number, offset: number) => {
+ 'use gpu';
+
+ const coord = (irot * (std.floor(rot * (uv * scale + offset)) + 0.5) - offset) / scale;
+ const color = std.textureSample(postProcessLayout.$.inTexture, fsampler.$, coord);
+
+ const dist = std.distance(uv, coord) * scale;
+ return CirclePattern({ color, dist });
+ };
+
+ function ss(pat: d.Infer, sharpness: number, bias: d.v4f) {
+ 'use gpu';
+ return d.vec4f(
+ std.smoothstep(0, sharpness, pat.dist + pat.color.x - bias.x),
+ std.smoothstep(0, sharpness, pat.dist + pat.color.y - bias.y),
+ std.smoothstep(0, sharpness, pat.dist + pat.color.z - bias.z),
+ std.smoothstep(0, sharpness, -pat.dist + pat.color.w - bias.w),
+ );
+ }
+
+ function sampleInk(uv: d.v2f) {
+ 'use gpu';
+ return std.textureSample(renderFluidSimLayout.$.inkTexture, fsampler.$, uv).x;
+ }
+
+ const postProcessFragmentFn = tgpu.fragmentFn({
+ in: { pixelCoord: d.builtin.position, uv: d.vec2f },
+ out: d.vec4f,
+ })((input) => {
+ 'use gpu';
+ const pixelStep = d.f32(1) / SIM_N;
+
+ const inkUv = d.vec2f(input.uv.x, 1 - input.uv.y);
+ const leftSample = sampleInk(d.vec2f(inkUv.x - pixelStep, inkUv.y));
+ const rightSample = sampleInk(d.vec2f(inkUv.x + pixelStep, inkUv.y));
+ const upSample = sampleInk(d.vec2f(inkUv.x, inkUv.y + pixelStep));
+ const downSample = sampleInk(d.vec2f(inkUv.x, inkUv.y - pixelStep));
+ const grad = d.vec2f(rightSample - leftSample, upSample - downSample);
+
+ const identity = d.mat2x2f(d.vec2f(1, 0), d.vec2f(0, 1));
+ const rot = d.mat2x2f(std.normalize(d.vec2f(1, 1)), std.normalize(d.vec2f(-1, 1)));
+ const irot = d.mat2x2f(std.normalize(d.vec2f(1, -1)), std.normalize(d.vec2f(1, 1)));
+ const pat1 = circlePattern(identity, identity, input.uv + grad * 3, d.f32(3), d.f32(0));
+ const pat2 = circlePattern(rot, irot, input.uv + grad * 0.02, d.f32(80), d.f32(0));
+
+ const tint = d.vec3f(0.9, 0.5, 1);
+ const c2 = ss(
+ pat2,
+ 0.1,
+ d.vec4f(
+ 0.5 + pat1.dist * 0.4 + downSample * 0.5,
+ 0.5 + pat1.dist * 0.4 + downSample * 0.5,
+ 0.5 + pat1.dist * 0.4,
+ 0.5 + pat1.dist * 0.4 - downSample * 0.3,
+ ),
+ );
+
+ const grayscale = d.vec4f(d.vec3f(c2.x + c2.y + c2.z + 0.8) * tint, 1) * 0.5 * c2.w;
+
+ return std.mix(grayscale, c2, std.smoothstep(0, 0.2, downSample));
+ });
+
+ const postProcessPipeline = root.createRenderPipeline({
+ vertex: fullScreenTriangle,
+ fragment: postProcessFragmentFn,
+ targets: { format: presentationFormat },
+ });
+
+ let running = true;
+ const frame = (timestamp: number) => {
+ if (!running) {
+ return;
+ }
+ requestAnimationFrame(frame);
+
+ if (resolution[0] !== canvas.width || resolution[1] !== canvas.height) {
+ onResize(canvas.width, canvas.height);
+ }
+
+ fluidSim.update();
+
+ const viewProjection = mat4.perspective(
+ 0.5,
+ resolution[0] / resolution[1],
+ 0.1,
+ 1000,
+ d.mat4x4f(),
+ );
+
+ const modelMatrix = mat4.identity(d.mat4x4f());
+ mat4.translate(modelMatrix, [0, -0.7, -10], modelMatrix);
+
+ mat4.rotateZ(modelMatrix, -0.1, modelMatrix);
+ mat4.rotateX(modelMatrix, 0.6, modelMatrix);
+
+ // Rotating around local y-axis
+ mat4.rotateY(modelMatrix, -timestamp * 0.00015, modelMatrix);
+
+ uniforms.write({
+ viewProjection,
+ modelMatrix,
+ });
+
+ const depthView = screenTextures.depthTexture.createView('render');
+ const modelRenderView = screenTextures.modelTexture.createView('render');
+
+ renderPipeline
+ .withIndexBuffer(model.body.indexBuffer)
+ .with(modelVertexLayout, model.body.vertexBuffer)
+ .withColorAttachment({ view: modelRenderView })
+ .withDepthStencilAttachment({
+ view: depthView,
+ depthClearValue: 1,
+ depthLoadOp: 'clear',
+ depthStoreOp: 'store',
+ })
+ .drawIndexed(model.body.indexCount);
+
+ renderPipeline
+ .withIndexBuffer(model.tail.indexBuffer)
+ .with(modelVertexLayout, model.tail.vertexBuffer)
+ .withColorAttachment({
+ view: modelRenderView,
+ loadOp: 'load',
+ storeOp: 'store',
+ })
+ .withDepthStencilAttachment({
+ view: depthView,
+ depthLoadOp: 'load',
+ depthStoreOp: 'store',
+ })
+ .drawIndexed(model.tail.indexCount);
+
+ // Post-processing
+ postProcessPipeline
+ .with(screenTextures.postProcessGroup)
+ .with(fluidSim.renderBindGroup)
+ .withColorAttachment({ view: context })
+ .draw(3);
+ };
+
+ requestAnimationFrame(frame);
+
+ return {
+ onCleanup() {
+ running = false;
+ fluidSim.destroy();
+ screenTextures.destroy();
+ uniforms.buffer.destroy();
+ model.destroy();
+ },
+ };
+}
diff --git a/apps/typegpu-docs/src/components/hero-effect/load-model.ts b/apps/typegpu-docs/src/components/hero-effect/load-model.ts
new file mode 100644
index 0000000000..2e8db44bd0
--- /dev/null
+++ b/apps/typegpu-docs/src/components/hero-effect/load-model.ts
@@ -0,0 +1,90 @@
+import { load } from '@loaders.gl/core';
+import { GLTFLoader, GLTFScenegraph } from '@loaders.gl/gltf';
+import { tgpu, d, common, type TgpuRoot } from 'typegpu';
+
+const ModelVertexInput = d.struct({
+ pos: d.vec3f,
+ normal: d.vec3f,
+});
+
+export const modelVertexLayout = tgpu.vertexLayout(d.arrayOf(ModelVertexInput));
+
+function createMeshBuffers(root: TgpuRoot, graph: GLTFScenegraph, meshIdx: number) {
+ const mesh = graph.getMesh(meshIdx);
+ const posPtr = mesh.primitives[0].attributes.POSITION;
+ const normalPtr = mesh.primitives[0].attributes.NORMAL;
+ const idxPtr = mesh.primitives[0].indices;
+ if (posPtr === undefined || normalPtr === undefined || idxPtr === undefined) {
+ throw new Error(`Missing required attributes: ${posPtr}, ${normalPtr}, ${idxPtr}`);
+ }
+
+ const posView = graph.getBufferView(posPtr);
+ const normalView = graph.getBufferView(normalPtr);
+ const idxView = graph.getBufferView(idxPtr);
+
+ const posBuffer = graph.gltf.buffers[posView.buffer];
+ const posBufferView = new Float32Array(
+ posBuffer.arrayBuffer,
+ posBuffer.byteOffset + (posView.byteOffset ?? 0),
+ posView.byteLength / 4,
+ );
+
+ const normalBuffer = graph.gltf.buffers[normalView.buffer];
+ const normalBufferView = new Float32Array(
+ normalBuffer.arrayBuffer,
+ normalBuffer.byteOffset + (normalView.byteOffset ?? 0),
+ normalView.byteLength / 4,
+ );
+
+ // Assuming u16 format for indices
+ const indexCount = idxView.byteLength / 2;
+ // Assuming f32 format for positions and normals
+ const vertexCount = posView.byteLength / 4 / 3;
+
+ const idxBuffer = graph.gltf.buffers[idxView.buffer];
+ const idxBufferView = new Uint16Array(
+ idxBuffer.arrayBuffer,
+ idxBuffer.byteOffset + (idxView.byteOffset ?? 0),
+ );
+
+ const vertexBufferGPU = root
+ .createBuffer(modelVertexLayout.schemaForCount(vertexCount), (buffer) => {
+ common.writeSoA(buffer, {
+ normal: normalBufferView,
+ pos: posBufferView,
+ });
+ })
+ .$usage('vertex')
+ .$name(`plum body vertices`);
+
+ const indexBufferGPU = root
+ .createBuffer(d.arrayOf(d.u16, indexCount), idxBufferView)
+ .$usage('index')
+ .$name(`plum body indices`);
+
+ return {
+ indexCount,
+ vertexBuffer: vertexBufferGPU,
+ indexBuffer: indexBufferGPU,
+ };
+}
+
+export async function loadModel(root: TgpuRoot) {
+ const modelMesh = await load('/TypeGPU/assets/plum.glb', GLTFLoader);
+ const graph = new GLTFScenegraph(modelMesh);
+
+ const body = createMeshBuffers(root, graph, 0);
+ const tail = createMeshBuffers(root, graph, 1);
+
+ return {
+ body,
+ tail,
+ destroy() {
+ body.indexBuffer.destroy();
+ body.vertexBuffer.destroy();
+
+ tail.indexBuffer.destroy();
+ tail.vertexBuffer.destroy();
+ },
+ };
+}
diff --git a/apps/typegpu-docs/src/components/hero-effect/screen-textures.ts b/apps/typegpu-docs/src/components/hero-effect/screen-textures.ts
new file mode 100644
index 0000000000..d9d93bdced
--- /dev/null
+++ b/apps/typegpu-docs/src/components/hero-effect/screen-textures.ts
@@ -0,0 +1,79 @@
+import tgpu, {
+ d,
+ type RenderFlag,
+ type SampledFlag,
+ type TgpuBindGroup,
+ type TgpuRoot,
+ type TgpuTexture,
+} from 'typegpu';
+
+export const postProcessLayout = tgpu.bindGroupLayout({
+ inTexture: { texture: d.texture2d() },
+});
+
+export class ScreenTextures {
+ #root: TgpuRoot;
+ #resolution: [number, number];
+
+ declare depthTexture: TgpuTexture<{
+ size: [number, number];
+ format: 'depth24plus';
+ }> &
+ RenderFlag;
+
+ declare modelTexture: TgpuTexture<{
+ size: [number, number];
+ format: GPUTextureFormat;
+ }> &
+ RenderFlag &
+ SampledFlag;
+
+ declare postProcessGroup: TgpuBindGroup<(typeof postProcessLayout)['entries']>;
+
+ constructor(root: TgpuRoot, resolution: [number, number]) {
+ this.#root = root;
+ this.#resolution = [...resolution];
+ this.recreate();
+ }
+
+ get resolution() {
+ return this.#resolution;
+ }
+
+ set resolution(resolution: [number, number]) {
+ if (resolution[0] === this.#resolution[0] && resolution[1] === this.#resolution[1]) {
+ return;
+ }
+
+ this.#resolution = resolution;
+ this.recreate();
+ }
+
+ recreate() {
+ if (this.depthTexture) this.depthTexture.destroy();
+ if (this.modelTexture) this.modelTexture.destroy();
+
+ this.depthTexture = this.#root
+ .createTexture({
+ size: this.#resolution,
+ format: 'depth24plus',
+ })
+ .$usage('render');
+
+ this.modelTexture = this.#root
+ .createTexture({
+ size: this.#resolution,
+ format: navigator.gpu.getPreferredCanvasFormat(),
+ })
+ .$usage('render', 'sampled');
+
+ this.postProcessGroup = this.#root.createBindGroup(postProcessLayout, {
+ inTexture: this.modelTexture,
+ });
+ }
+
+ destroy() {
+ if (this.depthTexture) this.depthTexture.destroy();
+ if (this.modelTexture) this.modelTexture.destroy();
+ }
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/Caustics.tsx b/apps/typegpu-docs/src/components/landing-examples/Caustics.tsx
new file mode 100644
index 0000000000..728af809b0
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/Caustics.tsx
@@ -0,0 +1,6 @@
+import { setupScene } from '../../examples/rendering/caustics/scene.ts';
+import HoverExampleLive from './HoverExampleLive.tsx';
+
+export default function Caustics() {
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/CubemapReflection.tsx b/apps/typegpu-docs/src/components/landing-examples/CubemapReflection.tsx
new file mode 100644
index 0000000000..8f9e2b2932
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/CubemapReflection.tsx
@@ -0,0 +1,6 @@
+import { setupScene } from '../../examples/rendering/cubemap-reflection/scene.ts';
+import HoverExampleLive from './HoverExampleLive.tsx';
+
+export default function CubemapReflection() {
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/Fish.tsx b/apps/typegpu-docs/src/components/landing-examples/Fish.tsx
new file mode 100644
index 0000000000..808e937e09
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/Fish.tsx
@@ -0,0 +1,6 @@
+import { setupScene } from '../../examples/rendering/3d-fish/scene.ts';
+import HoverExampleLive from './HoverExampleLive.tsx';
+
+export default function Fish() {
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/HoverExampleIsland.tsx b/apps/typegpu-docs/src/components/landing-examples/HoverExampleIsland.tsx
new file mode 100644
index 0000000000..07400ae88a
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/HoverExampleIsland.tsx
@@ -0,0 +1,94 @@
+import React, { useRef } from 'react';
+import type { PointerEvent as ReactPointerEvent, TouchEvent as ReactTouchEvent } from 'react';
+
+import ExternalOpenSvg from '../../assets/externalopen.svg';
+import { WebGPUErrorBoundary } from '../WebGPUErrorBoundary.tsx';
+import { isGPUSupported } from '../../utils/isGPUSupported.ts';
+import { useHydrated } from '../../utils/useHydrated.ts';
+
+interface HoverExampleIslandProps {
+ exampleKey: string;
+ title: string;
+ previewImageSrc: string;
+ liveComponent: React.ReactNode;
+ isActive: boolean;
+ onActivate: () => void;
+ onDeactivate: () => void;
+}
+
+export default function HoverExampleIsland({
+ exampleKey,
+ title,
+ previewImageSrc,
+ liveComponent,
+ isActive,
+ onActivate,
+ onDeactivate,
+}: HoverExampleIslandProps) {
+ const rootRef = useRef(null);
+ const twoFingerActiveRef = useRef(false);
+ const isHydrated = useHydrated();
+
+ const handlePointerEnter = (e: ReactPointerEvent) => e.pointerType !== 'touch' && onActivate();
+ const handlePointerLeave = (e: ReactPointerEvent) => e.pointerType !== 'touch' && onDeactivate();
+ const handleTouchStart = (e: ReactTouchEvent) => {
+ if (e.touches.length >= 2) {
+ e.preventDefault();
+ twoFingerActiveRef.current = true;
+ }
+ };
+ const handleTouchMove = (e: ReactTouchEvent) => {
+ if (twoFingerActiveRef.current) e.preventDefault();
+ };
+ const handleTouchEnd = (e: ReactTouchEvent) => {
+ if (e.touches.length === 0 && twoFingerActiveRef.current) {
+ twoFingerActiveRef.current = false;
+ if (isActive) {
+ onDeactivate();
+ } else {
+ onActivate();
+ }
+ }
+ };
+ const handleTouchCancel = () => {
+ twoFingerActiveRef.current = false;
+ };
+
+ return (
+
+
+ {title}
+
+
+
+
+
+
+ >}>
+ {isHydrated && isGPUSupported && isActive ? liveComponent : null}
+
+
+
+ );
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/HoverExampleLive.tsx b/apps/typegpu-docs/src/components/landing-examples/HoverExampleLive.tsx
new file mode 100644
index 0000000000..bc39d9887d
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/HoverExampleLive.tsx
@@ -0,0 +1,77 @@
+import type { TgpuRoot } from 'typegpu';
+import { useConfigureContext, useRoot } from '@typegpu/react';
+import { useEffect, useState } from 'react';
+
+interface ExampleState {
+ onCleanup(): void;
+}
+
+interface HoverExampleLiveProps {
+ setup: (root: TgpuRoot, context: GPUCanvasContext) => Promise;
+}
+
+export default function HoverExampleLive({ setup }: HoverExampleLiveProps) {
+ const root = useRoot();
+ const { ctxRef, ref: canvasRef } = useConfigureContext({
+ alphaMode: 'premultiplied',
+ });
+ const [isActive, setIsActive] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ let onCleanup: (() => void) | undefined;
+
+ void (async () => {
+ // Ref callbacks run during the commit phase, before passive effects, so
+ // the context is normally already configured here. Guard against the rare
+ // case where it isn't yet, retrying until it is (or we're cancelled).
+ let tries = 0;
+ while (!ctxRef.current) {
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ tries++;
+ if (tries > 100) {
+ setError(new Error('Too many retries, context is not available'));
+ return;
+ }
+ if (cancelled) return;
+ }
+ if (cancelled) return;
+
+ try {
+ const example = await setup(root, ctxRef.current);
+ onCleanup = () => example.onCleanup();
+
+ if (cancelled) {
+ onCleanup();
+ return;
+ }
+
+ setIsActive(true);
+ } catch (err) {
+ // Error boundaries can't catch throws from async effect callbacks, so
+ // surface the failure into render state to trip the boundary's fallback.
+ if (!cancelled) {
+ setError(err);
+ }
+ }
+ })();
+
+ return () => {
+ onCleanup?.();
+ cancelled = true;
+ };
+ }, [root, setup]);
+
+ if (error) {
+ throw error;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/JellySlider.tsx b/apps/typegpu-docs/src/components/landing-examples/JellySlider.tsx
new file mode 100644
index 0000000000..2a96e4e879
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/JellySlider.tsx
@@ -0,0 +1,14 @@
+import type { TgpuRoot } from 'typegpu';
+import { setupScene } from '../../examples/rendering/jelly-slider/scene.ts';
+import HoverExampleLive from './HoverExampleLive.tsx';
+
+async function setup(root: TgpuRoot, context: GPUCanvasContext) {
+ const scene = await setupScene(root, context);
+ scene.qualityScale = await scene.computeOptimalQuality();
+
+ return { onCleanup: () => scene.onCleanup() };
+}
+
+export default function JellySlider() {
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/LandingExampleGrid.tsx b/apps/typegpu-docs/src/components/landing-examples/LandingExampleGrid.tsx
new file mode 100644
index 0000000000..5673481afb
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/LandingExampleGrid.tsx
@@ -0,0 +1,89 @@
+import { lazy, useState } from 'react';
+import { Root } from '@typegpu/react';
+
+import CausticsThumbnail from '../../examples/rendering/caustics/thumbnail.png';
+import RayMarchingThumbnail from '../../examples/rendering/ray-marching/thumbnail.png';
+import JellySliderThumbnail from '../../examples/rendering/jelly-slider/thumbnail.png';
+import FishThumbnail from '../../examples/rendering/3d-fish/thumbnail.png';
+import CubemapReflectionThumbnail from '../../examples/rendering/cubemap-reflection/thumbnail.png';
+import VaporRaveThumbnail from '../../examples/simple/vaporrave/thumbnail.png';
+import HoverExampleIsland from './HoverExampleIsland.tsx';
+
+const JellySlider = lazy(() => import('./JellySlider.tsx'));
+const Fish = lazy(() => import('./Fish.tsx'));
+const Vaporrave = lazy(() => import('./Vaporrave.tsx'));
+const CubemapReflection = lazy(() => import('./CubemapReflection.tsx'));
+const Caustics = lazy(() => import('./Caustics.tsx'));
+const RayMarching = lazy(() => import('./RayMarching.tsx'));
+
+const galleryItems = [
+ {
+ asset: JellySliderThumbnail,
+ title: 'Jelly Slider',
+ key: 'rendering--jelly-slider',
+ liveComponent: JellySlider,
+ },
+ {
+ asset: FishThumbnail,
+ title: '3D Fish',
+ key: 'rendering--3d-fish',
+ liveComponent: Fish,
+ },
+ {
+ asset: VaporRaveThumbnail,
+ title: 'Vaporrave',
+ key: 'simple--vaporrave',
+ liveComponent: Vaporrave,
+ },
+ {
+ asset: CubemapReflectionThumbnail,
+ title: 'Cubemap Reflection',
+ key: 'rendering--cubemap-reflection',
+ liveComponent: CubemapReflection,
+ },
+ {
+ asset: CausticsThumbnail,
+ title: 'Caustics',
+ key: 'rendering--caustics',
+ liveComponent: Caustics,
+ },
+ {
+ asset: RayMarchingThumbnail,
+ title: 'Ray Marching',
+ key: 'rendering--ray-marching',
+ liveComponent: RayMarching,
+ },
+] as const;
+
+export default function TgpuExamples() {
+ // Only one example runs a live GPU render loop at a time.
+ const [activeKey, setActiveKey] = useState(null);
+
+ return (
+
+
+ Hint: tap the example with two fingers to preview
+
+
+
+ {galleryItems.map((item) => {
+ const LiveComponent = item.liveComponent;
+ return (
+ setActiveKey(item.key)}
+ onDeactivate={() =>
+ setActiveKey((current) => (current === item.key ? null : current))
+ }
+ liveComponent={ }
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/RayMarching.tsx b/apps/typegpu-docs/src/components/landing-examples/RayMarching.tsx
new file mode 100644
index 0000000000..55037fcb15
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/RayMarching.tsx
@@ -0,0 +1,6 @@
+import { setupScene } from '../../examples/rendering/ray-marching/scene.ts';
+import HoverExampleLive from './HoverExampleLive.tsx';
+
+export default function RayMarching() {
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/landing-examples/Vaporrave.tsx b/apps/typegpu-docs/src/components/landing-examples/Vaporrave.tsx
new file mode 100644
index 0000000000..26c954a06a
--- /dev/null
+++ b/apps/typegpu-docs/src/components/landing-examples/Vaporrave.tsx
@@ -0,0 +1,6 @@
+import { setupScene } from '../../examples/simple/vaporrave/scene.ts';
+import HoverExampleLive from './HoverExampleLive.tsx';
+
+export default function Vaporrave() {
+ return ;
+}
diff --git a/apps/typegpu-docs/src/components/GetStartedButton.astro b/apps/typegpu-docs/src/components/legacy/GetStartedButton.astro
similarity index 100%
rename from apps/typegpu-docs/src/components/GetStartedButton.astro
rename to apps/typegpu-docs/src/components/legacy/GetStartedButton.astro
diff --git a/apps/typegpu-docs/src/examples/rendering/3d-fish/index.ts b/apps/typegpu-docs/src/examples/rendering/3d-fish/index.ts
index e94a1d7b5d..1d0d578efd 100644
--- a/apps/typegpu-docs/src/examples/rendering/3d-fish/index.ts
+++ b/apps/typegpu-docs/src/examples/rendering/3d-fish/index.ts
@@ -1,451 +1,23 @@
-import { randf } from '@typegpu/noise';
-import tgpu, { d, std } from 'typegpu';
-import * as m from 'wgpu-matrix';
-import { simulate } from './compute.ts';
-import { loadModel } from './load-model.ts';
-import * as p from './params.ts';
-import { fragmentShader, vertexShader } from './render.ts';
-import {
- Camera,
- computeBindGroupLayout,
- FishBehaviorParams,
- Line3,
- ModelData,
- ModelDataArray,
- modelVertexLayout,
- renderBindGroupLayout,
- renderInstanceLayout,
-} from './schemas.ts';
-import { defineControls } from '../../common/defineControls.ts';
+import tgpu from 'typegpu';
-// setup
-let speedMultiplier = 1;
-let disposed = false;
+import { defineControls } from '../../common/defineControls.ts';
+import { setupScene } from './scene.ts';
const root = await tgpu.init();
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
-
-// models and textures
-
-const presets = {
- default: {
- separationDist: 0.3,
- separationStr: 0.0006,
- alignmentDist: 0.3,
- alignmentStr: 0.01,
- cohesionDist: 0.5,
- cohesionStr: 0.0013,
- },
- init: {
- separationDist: 0.2,
- separationStr: 0.1,
- alignmentDist: 0.5,
- alignmentStr: 1,
- cohesionDist: 0.3,
- cohesionStr: 0.013,
- },
-} as const;
-
-const spinnerBackground = document.querySelector('.spinner-background') as HTMLDivElement;
-
-// https://sketchfab.com/3d-models/animated-low-poly-fish-64adc2e5a4be471e8279532b9610c878
-const fishModel = await loadModel(
- root,
- '/TypeGPU/assets/3d-fish/fish.obj',
- '/TypeGPU/assets/3d-fish/fish.jpg',
-);
-
-// https://www.cgtrader.com/free-3d-models/space/other/rainy-ocean
-// https://www.rawpixel.com/image/6032317/white-sand-texture-free-public-domain-cc0-photo
-const oceanFloorModel = await loadModel(
- root,
- '/TypeGPU/assets/3d-fish/ocean_floor.obj',
- '/TypeGPU/assets/3d-fish/ocean_floor.png',
-);
-
-// buffers
-
-const fishDataBuffers = Array.from({ length: 2 }, (_, idx) =>
- root
- .createBuffer(ModelDataArray(p.fishAmount))
- .$usage('storage', 'vertex')
- .$name(`fish data ${idx}`),
-);
-
-function enqueuePresetChanges() {
- speedMultiplier = 3;
- spinnerBackground.style.display = 'grid';
- fishBehaviorBuffer.write(presets.init);
-
- window.setTimeout(() => {
- if (disposed) return;
- fishBehaviorBuffer.write(presets.default);
- spinnerBackground.style.display = 'none';
- speedMultiplier = 1;
- }, 300);
-}
-
-const buffer0mutable = fishDataBuffers[0].as('mutable');
-const buffer1mutable = fishDataBuffers[1].as('mutable');
-const seedUniform = root.createUniform(d.f32);
-const randomizeFishPositionsPipeline = root.createGuardedComputePipeline((x) => {
- 'use gpu';
- randf.seed2(d.vec2f(x, seedUniform.$));
- const data = ModelData({
- position: d.vec3f(
- randf.sample() * p.aquariumSize.x - p.aquariumSize.x / 2,
- randf.sample() * p.aquariumSize.y - p.aquariumSize.y / 2,
- randf.sample() * p.aquariumSize.z - p.aquariumSize.z / 2,
- ),
- direction: d.vec3f(
- randf.sample() * 0.1 - 0.05,
- randf.sample() * 0.1 - 0.05,
- randf.sample() * 0.1 - 0.05,
- ),
- scale: p.fishModelScale * (1 + (randf.sample() - 0.5) * 0.8),
- variant: randf.sample(),
- applySinWave: 1,
- applySeaFog: 1,
- applySeaDesaturation: 1,
- });
- buffer0mutable.$[x] = ModelData(data);
- buffer1mutable.$[x] = ModelData(data);
-});
-
-const randomizeFishPositions = () => {
- seedUniform.write((performance.now() % 10000) / 10000);
- randomizeFishPositionsPipeline.dispatchThreads(p.fishAmount);
- enqueuePresetChanges();
-};
-
-const camera = {
- position: p.cameraInitialPosition,
- targetPos: p.cameraInitialTarget,
- view: m.mat4.lookAt(
- p.cameraInitialPosition,
- p.cameraInitialTarget,
- d.vec3f(0, 1, 0),
- d.mat4x4f(),
- ),
- projection: m.mat4.perspective(
- Math.PI / 4,
- canvas.clientWidth / canvas.clientHeight,
- 0.1,
- 1000,
- d.mat4x4f(),
- ),
-};
-
-const cameraBuffer = root.createBuffer(Camera, camera).$usage('uniform');
-const mouseRayBuffer = root.createBuffer(Line3).$usage('uniform');
-const timePassedBuffer = root.createBuffer(d.f32).$usage('uniform');
-const currentTimeBuffer = root.createBuffer(d.f32).$usage('uniform');
-
-const fishBehaviorBuffer = root.createBuffer(FishBehaviorParams, presets.default).$usage('uniform');
-
-const oceanFloorDataBuffer = root
- .createBuffer(ModelDataArray(1), [
- {
- position: d.vec3f(0, -p.aquariumSize.y / 2 - 1, 0),
- direction: d.vec3f(1, 0, 0),
- scale: 1,
- variant: 0,
- applySinWave: 0,
- applySeaFog: 1,
- applySeaDesaturation: 0,
- },
- ])
- .$usage('storage', 'vertex');
-
-randomizeFishPositions();
-
-// pipelines
-
-const renderPipeline = root.createRenderPipeline({
- attribs: modelVertexLayout.attrib,
- vertex: vertexShader,
- fragment: fragmentShader,
-
- depthStencil: {
- format: 'depth24plus',
- depthWriteEnabled: true,
- depthCompare: 'less',
- },
-});
-
-let depthTexture = root.device.createTexture({
- size: [canvas.width, canvas.height, 1],
- format: 'depth24plus',
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
-});
-
-const simulatePipeline = root.createGuardedComputePipeline(simulate);
-
-// bind groups
-
-const sampler = root.device.createSampler({
- addressModeU: 'repeat',
- addressModeV: 'repeat',
- magFilter: 'linear',
- minFilter: 'linear',
-});
-
-const renderFishBindGroups = [0, 1].map((idx) =>
- root.createBindGroup(renderBindGroupLayout, {
- modelData: fishDataBuffers[idx],
- camera: cameraBuffer,
- modelTexture: fishModel.texture,
- sampler: sampler,
- currentTime: currentTimeBuffer,
- }),
-);
-
-const renderOceanFloorBindGroup = root.createBindGroup(renderBindGroupLayout, {
- modelData: oceanFloorDataBuffer,
- camera: cameraBuffer,
- modelTexture: oceanFloorModel.texture,
- sampler: sampler,
- currentTime: currentTimeBuffer,
-});
-
-const computeBindGroups = [0, 1].map((idx) =>
- root.createBindGroup(computeBindGroupLayout, {
- currentFishData: fishDataBuffers[idx],
- nextFishData: fishDataBuffers[1 - idx],
- mouseRay: mouseRayBuffer,
- timePassed: timePassedBuffer,
- fishBehavior: fishBehaviorBuffer,
- }),
-);
-
-// frame
-
-let odd = false;
-let lastTimestamp: DOMHighResTimeStamp = 0;
-let animationFrameId: number;
-
-function frame(timestamp: DOMHighResTimeStamp) {
- odd = !odd;
-
- currentTimeBuffer.write(timestamp);
- timePassedBuffer.write((timestamp - lastTimestamp) * speedMultiplier);
- lastTimestamp = timestamp;
- cameraBuffer.write(camera);
-
- simulatePipeline.with(computeBindGroups[odd ? 1 : 0]).dispatchThreads(p.fishAmount);
-
- renderPipeline
- .withColorAttachment({
- view: context,
- clearValue: [p.backgroundColor.x, p.backgroundColor.y, p.backgroundColor.z, 1],
- })
- .withDepthStencilAttachment({
- view: depthTexture.createView(),
- depthClearValue: 1,
- depthLoadOp: 'clear',
- depthStoreOp: 'store',
- })
- .with(modelVertexLayout, oceanFloorModel.vertexBuffer)
- .with(renderInstanceLayout, oceanFloorDataBuffer)
- .with(renderOceanFloorBindGroup)
- .draw(oceanFloorModel.polygonCount, 1);
-
- renderPipeline
- .withColorAttachment({
- view: context,
- clearValue: [p.backgroundColor.x, p.backgroundColor.y, p.backgroundColor.z, 1],
- loadOp: 'load',
- })
- .withDepthStencilAttachment({
- view: depthTexture.createView(),
- depthClearValue: 1,
- depthLoadOp: 'load',
- depthStoreOp: 'store',
- })
- .with(modelVertexLayout, fishModel.vertexBuffer)
- .with(renderInstanceLayout, fishDataBuffers[odd ? 1 : 0])
- .with(renderFishBindGroups[odd ? 1 : 0])
- .draw(fishModel.polygonCount, p.fishAmount);
-
- animationFrameId = requestAnimationFrame(frame);
-}
-enqueuePresetChanges();
-animationFrameId = requestAnimationFrame(frame);
+const scene = await setupScene(root, context);
// #region Example controls and cleanup
export const controls = defineControls({
'Randomize positions': {
- onButtonClick: randomizeFishPositions,
+ onButtonClick: scene.randomizeFishPositions,
},
});
-// Variables for interaction
-
-let isPressed = false;
-let previousMouseX = 0;
-let previousMouseY = 0;
-
-let isPopupDiscarded = false;
-const controlsPopup = document.getElementById('help') as HTMLDivElement;
-
-const cameraRadius = std.length(std.sub(p.cameraInitialPosition.xyz, p.cameraInitialTarget.xyz));
-let cameraYaw =
- (Math.atan2(p.cameraInitialPosition.x, p.cameraInitialPosition.z) + Math.PI) % Math.PI;
-let cameraPitch = Math.asin(p.cameraInitialPosition.y / cameraRadius);
-
-function updateCameraTarget(cx: number, cy: number) {
- // make it so the drag does the same movement regardless of size
- const box = canvas.getBoundingClientRect();
- const dx = cx / box.width;
- const dy = cy / box.height;
-
- cameraYaw += dx * 2.5;
- cameraPitch += dy * 2.5;
-
- cameraYaw = std.clamp(cameraYaw, (Math.PI / 4) * -0.2, (Math.PI / 4) * 2.2);
- cameraPitch = std.clamp(cameraPitch, -Math.PI / 4, Math.PI / 4);
-
- const newCamX = cameraRadius * Math.sin(cameraYaw) * Math.cos(cameraPitch);
- const newCamY = cameraRadius * Math.sin(cameraPitch);
- const newCamZ = cameraRadius * Math.cos(cameraYaw) * Math.cos(cameraPitch);
-
- camera.targetPos = d.vec4f(newCamX, newCamY, newCamZ, 1);
- camera.view = m.mat4.lookAt(
- p.cameraInitialPosition,
- camera.targetPos,
- d.vec3f(0, 1, 0),
- d.mat4x4f(),
- );
-}
-
-function updateMouseRay(cx: number, cy: number) {
- const boundingBox = canvas.getBoundingClientRect();
- const canvasX = Math.floor((cx - boundingBox.left) * window.devicePixelRatio);
- const canvasY = Math.floor((cy - boundingBox.top) * window.devicePixelRatio);
- const canvasPoint = d.vec4f(
- (canvasX / canvas.width) * 2 - 1,
- (1 - canvasY / canvas.height) * 2 - 1,
- 0,
- 1,
- );
-
- const invView = m.mat4.inverse(camera.view, d.mat4x4f());
- const invProj = m.mat4.inverse(camera.projection, d.mat4x4f());
- const intermediate = std.mul(invProj, canvasPoint);
- const worldPos = std.mul(invView, intermediate);
- const worldPosNonUniform = d.vec3f(
- worldPos.x / worldPos.w,
- worldPos.y / worldPos.w,
- worldPos.z / worldPos.w,
- );
-
- mouseRayBuffer.write(
- Line3({
- origin: camera.position.xyz,
- dir: std.normalize(std.sub(worldPosNonUniform, camera.position.xyz)),
- }),
- );
-}
-
-// Mouse controls
-
-canvas.addEventListener('mousedown', async (event) => {
- previousMouseX = event.clientX;
- previousMouseY = event.clientY;
- controlsPopup.style.opacity = '0';
- isPopupDiscarded = true;
-
- if (event.button === 0) {
- isPressed = true;
- }
- updateMouseRay(event.clientX, event.clientY);
-});
-
-const mouseUpEventListener = (event: MouseEvent) => {
- if (event.button === 0) {
- isPressed = false;
- }
-};
-window.addEventListener('mouseup', mouseUpEventListener);
-
-canvas.addEventListener('mousemove', () => {
- if (!isPopupDiscarded) {
- controlsPopup.style.opacity = '1';
- }
-});
-
-const mouseMoveEventListener = (event: MouseEvent) => {
- const dx = event.clientX - previousMouseX;
- const dy = event.clientY - previousMouseY;
- previousMouseX = event.clientX;
- previousMouseY = event.clientY;
-
- if (isPressed) {
- updateCameraTarget(dx, dy);
- }
-
- updateMouseRay(event.clientX, event.clientY);
-};
-window.addEventListener('mousemove', mouseMoveEventListener);
-
-// Touch controls
-
-canvas.addEventListener(
- 'touchstart',
- async (event) => {
- event.preventDefault();
- if (event.touches.length === 1) {
- previousMouseX = event.touches[0].clientX;
- previousMouseY = event.touches[0].clientY;
- }
- updateMouseRay(event.touches[0].clientX, event.touches[0].clientY);
- controlsPopup.style.opacity = '0';
- },
- { passive: false },
-);
-
-const touchMoveEventListener = (event: TouchEvent) => {
- if (event.touches.length === 1) {
- const dx = event.touches[0].clientX - previousMouseX;
- const dy = event.touches[0].clientY - previousMouseY;
- previousMouseX = event.touches[0].clientX;
- previousMouseY = event.touches[0].clientY;
-
- updateCameraTarget(dx, dy);
- }
- updateMouseRay(event.touches[0].clientX, event.touches[0].clientY);
-};
-window.addEventListener('touchmove', touchMoveEventListener);
-
-// observer and cleanup
-
-const resizeObserver = new ResizeObserver(() => {
- camera.projection = m.mat4.perspective(
- Math.PI / 4,
- canvas.clientWidth / canvas.clientHeight,
- 0.1,
- 1000,
- d.mat4x4f(),
- );
-
- depthTexture.destroy();
- depthTexture = root.device.createTexture({
- size: [canvas.width, canvas.height, 1],
- format: 'depth24plus',
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
- });
-});
-resizeObserver.observe(canvas);
-
export function onCleanup() {
- disposed = true;
- cancelAnimationFrame(animationFrameId);
- window.removeEventListener('mouseup', mouseUpEventListener);
- window.removeEventListener('mousemove', mouseMoveEventListener);
- window.removeEventListener('touchmove', touchMoveEventListener);
- resizeObserver.disconnect();
+ scene.onCleanup();
root.destroy();
}
diff --git a/apps/typegpu-docs/src/examples/rendering/3d-fish/scene.ts b/apps/typegpu-docs/src/examples/rendering/3d-fish/scene.ts
new file mode 100644
index 0000000000..3a3682ffcf
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/3d-fish/scene.ts
@@ -0,0 +1,454 @@
+import { randf } from '@typegpu/noise';
+import { d, std, type TgpuRoot } from 'typegpu';
+import * as m from 'wgpu-matrix';
+import { simulate } from './compute.ts';
+import { loadModel } from './load-model.ts';
+import * as p from './params.ts';
+import { fragmentShader, vertexShader } from './render.ts';
+import {
+ Camera,
+ computeBindGroupLayout,
+ FishBehaviorParams,
+ Line3,
+ ModelData,
+ ModelDataArray,
+ modelVertexLayout,
+ renderBindGroupLayout,
+ renderInstanceLayout,
+} from './schemas.ts';
+
+// setup
+export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
+ const canvas = context.canvas as HTMLCanvasElement;
+ let speedMultiplier = 1;
+ let disposed = false;
+
+ // models and textures
+
+ const presets = {
+ default: {
+ separationDist: 0.3,
+ separationStr: 0.0006,
+ alignmentDist: 0.3,
+ alignmentStr: 0.01,
+ cohesionDist: 0.5,
+ cohesionStr: 0.0013,
+ },
+ init: {
+ separationDist: 0.2,
+ separationStr: 0.1,
+ alignmentDist: 0.5,
+ alignmentStr: 1,
+ cohesionDist: 0.3,
+ cohesionStr: 0.013,
+ },
+ } as const;
+
+ const spinnerBackground = document.querySelector('.spinner-background') as HTMLDivElement | null;
+
+ // https://sketchfab.com/3d-models/animated-low-poly-fish-64adc2e5a4be471e8279532b9610c878
+ const fishModel = await loadModel(
+ root,
+ '/TypeGPU/assets/3d-fish/fish.obj',
+ '/TypeGPU/assets/3d-fish/fish.jpg',
+ );
+
+ // https://www.cgtrader.com/free-3d-models/space/other/rainy-ocean
+ // https://www.rawpixel.com/image/6032317/white-sand-texture-free-public-domain-cc0-photo
+ const oceanFloorModel = await loadModel(
+ root,
+ '/TypeGPU/assets/3d-fish/ocean_floor.obj',
+ '/TypeGPU/assets/3d-fish/ocean_floor.png',
+ );
+
+ // buffers
+
+ const fishDataBuffers = Array.from({ length: 2 }, (_, idx) =>
+ root
+ .createBuffer(ModelDataArray(p.fishAmount))
+ .$usage('storage', 'vertex')
+ .$name(`fish data ${idx}`),
+ );
+
+ function enqueuePresetChanges() {
+ speedMultiplier = 3;
+ if (spinnerBackground) {
+ spinnerBackground.style.display = 'grid';
+ }
+ fishBehaviorBuffer.write(presets.init);
+
+ window.setTimeout(() => {
+ if (disposed) return;
+ fishBehaviorBuffer.write(presets.default);
+ if (spinnerBackground) {
+ spinnerBackground.style.display = 'none';
+ }
+ speedMultiplier = 1;
+ }, 300);
+ }
+
+ const buffer0mutable = fishDataBuffers[0].as('mutable');
+ const buffer1mutable = fishDataBuffers[1].as('mutable');
+ const seedUniform = root.createUniform(d.f32);
+ const randomizeFishPositionsPipeline = root.createGuardedComputePipeline((x) => {
+ 'use gpu';
+ randf.seed2(d.vec2f(x, seedUniform.$));
+ const data = ModelData({
+ position: d.vec3f(
+ randf.sample() * p.aquariumSize.x - p.aquariumSize.x / 2,
+ randf.sample() * p.aquariumSize.y - p.aquariumSize.y / 2,
+ randf.sample() * p.aquariumSize.z - p.aquariumSize.z / 2,
+ ),
+ direction: d.vec3f(
+ randf.sample() * 0.1 - 0.05,
+ randf.sample() * 0.1 - 0.05,
+ randf.sample() * 0.1 - 0.05,
+ ),
+ scale: p.fishModelScale * (1 + (randf.sample() - 0.5) * 0.8),
+ variant: randf.sample(),
+ applySinWave: 1,
+ applySeaFog: 1,
+ applySeaDesaturation: 1,
+ });
+ buffer0mutable.$[x] = ModelData(data);
+ buffer1mutable.$[x] = ModelData(data);
+ });
+
+ const randomizeFishPositions = () => {
+ seedUniform.write((performance.now() % 10000) / 10000);
+ randomizeFishPositionsPipeline.dispatchThreads(p.fishAmount);
+ enqueuePresetChanges();
+ };
+
+ const camera = {
+ position: p.cameraInitialPosition,
+ targetPos: p.cameraInitialTarget,
+ view: m.mat4.lookAt(
+ p.cameraInitialPosition,
+ p.cameraInitialTarget,
+ d.vec3f(0, 1, 0),
+ d.mat4x4f(),
+ ),
+ projection: m.mat4.perspective(
+ Math.PI / 4,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.1,
+ 1000,
+ d.mat4x4f(),
+ ),
+ };
+
+ const cameraBuffer = root.createBuffer(Camera, camera).$usage('uniform');
+ const mouseRayBuffer = root.createBuffer(Line3).$usage('uniform');
+ const timePassedBuffer = root.createBuffer(d.f32).$usage('uniform');
+ const currentTimeBuffer = root.createBuffer(d.f32).$usage('uniform');
+
+ const fishBehaviorBuffer = root
+ .createBuffer(FishBehaviorParams, presets.default)
+ .$usage('uniform');
+
+ const oceanFloorDataBuffer = root
+ .createBuffer(ModelDataArray(1), [
+ {
+ position: d.vec3f(0, -p.aquariumSize.y / 2 - 1, 0),
+ direction: d.vec3f(1, 0, 0),
+ scale: 1,
+ variant: 0,
+ applySinWave: 0,
+ applySeaFog: 1,
+ applySeaDesaturation: 0,
+ },
+ ])
+ .$usage('storage', 'vertex');
+
+ randomizeFishPositions();
+
+ // pipelines
+
+ const renderPipeline = root.createRenderPipeline({
+ attribs: modelVertexLayout.attrib,
+ vertex: vertexShader,
+ fragment: fragmentShader,
+
+ depthStencil: {
+ format: 'depth24plus',
+ depthWriteEnabled: true,
+ depthCompare: 'less',
+ },
+ });
+
+ let depthTexture = root.device.createTexture({
+ size: [canvas.width, canvas.height, 1],
+ format: 'depth24plus',
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+
+ const simulatePipeline = root.createGuardedComputePipeline(simulate);
+
+ // bind groups
+
+ const sampler = root.device.createSampler({
+ addressModeU: 'repeat',
+ addressModeV: 'repeat',
+ magFilter: 'linear',
+ minFilter: 'linear',
+ });
+
+ const renderFishBindGroups = [0, 1].map((idx) =>
+ root.createBindGroup(renderBindGroupLayout, {
+ modelData: fishDataBuffers[idx],
+ camera: cameraBuffer,
+ modelTexture: fishModel.texture,
+ sampler: sampler,
+ currentTime: currentTimeBuffer,
+ }),
+ );
+
+ const renderOceanFloorBindGroup = root.createBindGroup(renderBindGroupLayout, {
+ modelData: oceanFloorDataBuffer,
+ camera: cameraBuffer,
+ modelTexture: oceanFloorModel.texture,
+ sampler: sampler,
+ currentTime: currentTimeBuffer,
+ });
+
+ const computeBindGroups = [0, 1].map((idx) =>
+ root.createBindGroup(computeBindGroupLayout, {
+ currentFishData: fishDataBuffers[idx],
+ nextFishData: fishDataBuffers[1 - idx],
+ mouseRay: mouseRayBuffer,
+ timePassed: timePassedBuffer,
+ fishBehavior: fishBehaviorBuffer,
+ }),
+ );
+
+ // frame
+
+ let odd = false;
+ let lastTimestamp: DOMHighResTimeStamp = 0;
+ let animationFrameId: number;
+
+ function frame(timestamp: DOMHighResTimeStamp) {
+ odd = !odd;
+
+ currentTimeBuffer.write(timestamp);
+ timePassedBuffer.write((timestamp - lastTimestamp) * speedMultiplier);
+ lastTimestamp = timestamp;
+ cameraBuffer.write(camera);
+
+ simulatePipeline.with(computeBindGroups[odd ? 1 : 0]).dispatchThreads(p.fishAmount);
+
+ renderPipeline
+ .withColorAttachment({
+ view: context,
+ clearValue: [p.backgroundColor.x, p.backgroundColor.y, p.backgroundColor.z, 1],
+ })
+ .withDepthStencilAttachment({
+ view: depthTexture.createView(),
+ depthClearValue: 1,
+ depthLoadOp: 'clear',
+ depthStoreOp: 'store',
+ })
+ .with(modelVertexLayout, oceanFloorModel.vertexBuffer)
+ .with(renderInstanceLayout, oceanFloorDataBuffer)
+ .with(renderOceanFloorBindGroup)
+ .draw(oceanFloorModel.polygonCount, 1);
+
+ renderPipeline
+ .withColorAttachment({
+ view: context,
+ clearValue: [p.backgroundColor.x, p.backgroundColor.y, p.backgroundColor.z, 1],
+ loadOp: 'load',
+ })
+ .withDepthStencilAttachment({
+ view: depthTexture.createView(),
+ depthClearValue: 1,
+ depthLoadOp: 'load',
+ depthStoreOp: 'store',
+ })
+ .with(modelVertexLayout, fishModel.vertexBuffer)
+ .with(renderInstanceLayout, fishDataBuffers[odd ? 1 : 0])
+ .with(renderFishBindGroups[odd ? 1 : 0])
+ .draw(fishModel.polygonCount, p.fishAmount);
+
+ animationFrameId = requestAnimationFrame(frame);
+ }
+ enqueuePresetChanges();
+ animationFrameId = requestAnimationFrame(frame);
+
+ // Variables for interaction
+
+ let isPressed = false;
+ let previousMouseX = 0;
+ let previousMouseY = 0;
+
+ let isPopupDiscarded = false;
+ const controlsPopup = document.getElementById('help') as HTMLDivElement | null;
+
+ const cameraRadius = std.length(std.sub(p.cameraInitialPosition.xyz, p.cameraInitialTarget.xyz));
+ let cameraYaw =
+ (Math.atan2(p.cameraInitialPosition.x, p.cameraInitialPosition.z) + Math.PI) % Math.PI;
+ let cameraPitch = Math.asin(p.cameraInitialPosition.y / cameraRadius);
+
+ function updateCameraTarget(cx: number, cy: number) {
+ // make it so the drag does the same movement regardless of size
+ const box = canvas.getBoundingClientRect();
+ const dx = cx / box.width;
+ const dy = cy / box.height;
+
+ cameraYaw += dx * 2.5;
+ cameraPitch += dy * 2.5;
+
+ cameraYaw = std.clamp(cameraYaw, (Math.PI / 4) * -0.2, (Math.PI / 4) * 2.2);
+ cameraPitch = std.clamp(cameraPitch, -Math.PI / 4, Math.PI / 4);
+
+ const newCamX = cameraRadius * Math.sin(cameraYaw) * Math.cos(cameraPitch);
+ const newCamY = cameraRadius * Math.sin(cameraPitch);
+ const newCamZ = cameraRadius * Math.cos(cameraYaw) * Math.cos(cameraPitch);
+
+ camera.targetPos = d.vec4f(newCamX, newCamY, newCamZ, 1);
+ camera.view = m.mat4.lookAt(
+ p.cameraInitialPosition,
+ camera.targetPos,
+ d.vec3f(0, 1, 0),
+ d.mat4x4f(),
+ );
+ }
+
+ function updateMouseRay(cx: number, cy: number) {
+ const boundingBox = canvas.getBoundingClientRect();
+ const canvasX = Math.floor((cx - boundingBox.left) * window.devicePixelRatio);
+ const canvasY = Math.floor((cy - boundingBox.top) * window.devicePixelRatio);
+ const canvasPoint = d.vec4f(
+ (canvasX / canvas.width) * 2 - 1,
+ (1 - canvasY / canvas.height) * 2 - 1,
+ 0,
+ 1,
+ );
+
+ const invView = m.mat4.inverse(camera.view, d.mat4x4f());
+ const invProj = m.mat4.inverse(camera.projection, d.mat4x4f());
+ const intermediate = std.mul(invProj, canvasPoint);
+ const worldPos = std.mul(invView, intermediate);
+ const worldPosNonUniform = d.vec3f(
+ worldPos.x / worldPos.w,
+ worldPos.y / worldPos.w,
+ worldPos.z / worldPos.w,
+ );
+
+ mouseRayBuffer.write(
+ Line3({
+ origin: camera.position.xyz,
+ dir: std.normalize(std.sub(worldPosNonUniform, camera.position.xyz)),
+ }),
+ );
+ }
+
+ // Mouse controls
+
+ canvas.addEventListener('mousedown', async (event) => {
+ previousMouseX = event.clientX;
+ previousMouseY = event.clientY;
+ if (controlsPopup) {
+ controlsPopup.style.opacity = '0';
+ }
+ isPopupDiscarded = true;
+
+ if (event.button === 0) {
+ isPressed = true;
+ }
+ updateMouseRay(event.clientX, event.clientY);
+ });
+
+ const mouseUpEventListener = (event: MouseEvent) => {
+ if (event.button === 0) {
+ isPressed = false;
+ }
+ };
+ window.addEventListener('mouseup', mouseUpEventListener);
+
+ canvas.addEventListener('mousemove', () => {
+ if (!isPopupDiscarded && controlsPopup) {
+ controlsPopup.style.opacity = '1';
+ }
+ });
+
+ const mouseMoveEventListener = (event: MouseEvent) => {
+ const dx = event.clientX - previousMouseX;
+ const dy = event.clientY - previousMouseY;
+ previousMouseX = event.clientX;
+ previousMouseY = event.clientY;
+
+ if (isPressed) {
+ updateCameraTarget(dx, dy);
+ }
+
+ updateMouseRay(event.clientX, event.clientY);
+ };
+ window.addEventListener('mousemove', mouseMoveEventListener);
+
+ // Touch controls
+
+ canvas.addEventListener(
+ 'touchstart',
+ async (event) => {
+ event.preventDefault();
+ if (event.touches.length === 1) {
+ previousMouseX = event.touches[0].clientX;
+ previousMouseY = event.touches[0].clientY;
+ }
+ updateMouseRay(event.touches[0].clientX, event.touches[0].clientY);
+ if (controlsPopup) {
+ controlsPopup.style.opacity = '0';
+ }
+ },
+ { passive: false },
+ );
+
+ const touchMoveEventListener = (event: TouchEvent) => {
+ if (event.touches.length === 1) {
+ const dx = event.touches[0].clientX - previousMouseX;
+ const dy = event.touches[0].clientY - previousMouseY;
+ previousMouseX = event.touches[0].clientX;
+ previousMouseY = event.touches[0].clientY;
+
+ updateCameraTarget(dx, dy);
+ }
+ updateMouseRay(event.touches[0].clientX, event.touches[0].clientY);
+ };
+ window.addEventListener('touchmove', touchMoveEventListener);
+
+ // observer and cleanup
+
+ const resizeObserver = new ResizeObserver(() => {
+ camera.projection = m.mat4.perspective(
+ Math.PI / 4,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.1,
+ 1000,
+ d.mat4x4f(),
+ );
+
+ depthTexture.destroy();
+ depthTexture = root.device.createTexture({
+ size: [canvas.width, canvas.height, 1],
+ format: 'depth24plus',
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+ });
+ resizeObserver.observe(canvas);
+
+ return {
+ randomizeFishPositions,
+ onCleanup() {
+ disposed = true;
+ cancelAnimationFrame(animationFrameId);
+ window.removeEventListener('mouseup', mouseUpEventListener);
+ window.removeEventListener('mousemove', mouseMoveEventListener);
+ window.removeEventListener('touchmove', touchMoveEventListener);
+ resizeObserver.disconnect();
+ },
+ };
+}
+
+// #endregion
diff --git a/apps/typegpu-docs/src/examples/rendering/caustics/index.ts b/apps/typegpu-docs/src/examples/rendering/caustics/index.ts
index 5b970d312b..3cbbc1df4e 100644
--- a/apps/typegpu-docs/src/examples/rendering/caustics/index.ts
+++ b/apps/typegpu-docs/src/examples/rendering/caustics/index.ts
@@ -1,150 +1,12 @@
-import { perlin3d } from '@typegpu/noise';
-import tgpu, { d, std } from 'typegpu';
-import { defineControls } from '../../common/defineControls.ts';
-
-const mainVertex = tgpu.vertexFn({
- in: { vertexIndex: d.builtin.vertexIndex },
- out: { pos: d.builtin.position, uv: d.vec2f },
-})(({ vertexIndex }) => {
- const pos = [d.vec2f(0, 0.8), d.vec2f(-0.8, -0.8), d.vec2f(0.8, -0.8)];
- const uv = [d.vec2f(0.5, 1), d.vec2f(0, 0), d.vec2f(1, 0)];
-
- return {
- pos: d.vec4f(pos[vertexIndex], 0, 1),
- uv: uv[vertexIndex],
- };
-});
+import tgpu from 'typegpu';
-/**
- * Given a coordinate, it returns a grayscale floor tile pattern at that
- * location.
- */
-const tilePattern = (uv: d.v2f): number => {
- 'use gpu';
- const tiledUv = std.fract(uv);
- const proximity = std.abs(tiledUv * 2 - 1);
- const maxProximity = std.max(proximity.x, proximity.y);
- return std.saturate((1 - maxProximity) ** 0.6 * 5);
-};
-
-const caustics = (uv: d.v2f, time: number, profile: d.v3f): d.v3f => {
- 'use gpu';
- const distortion = perlin3d.sample(d.vec3f(uv * 0.5, time * 0.2));
- // Distorting UV coordinates
- const uv2 = uv + distortion;
- const noise = std.abs(perlin3d.sample(d.vec3f(uv2 * 5, time)));
- return std.pow(d.vec3f(1 - noise), profile);
-};
-
-/**
- * Returns a transformation matrix that represents an `angle` rotation
- * in the XY plane (around the imaginary Z axis)
- */
-const rotateXY = (angle: number): d.m2x2f => {
- 'use gpu';
- return d.mat2x2f(
- /* right */ d.vec2f(std.cos(angle), std.sin(angle)),
- /* up */ d.vec2f(-std.sin(angle), std.cos(angle)),
- );
-};
+import { defineControls } from '../../common/defineControls.ts';
+import { setupScene } from './scene.ts';
const root = await tgpu.init();
-
-/** Seconds passed since the start of the example, wrapped to the range [0, 1000) */
-const time = root.createUniform(d.f32);
-/** Controls the angle of rotation for the pool tile texture */
-const angle = 0.2;
-/** The bigger the number, the denser the pool tile texture is */
-const tileDensity = root.createUniform(d.f32);
-/** The scene fades into this color at a distance */
-const fogColor = d.vec3f(0.05, 0.2, 0.7);
-/** The ambient light color */
-const ambientColor = d.vec3f(0.2, 0.5, 1);
-
-const mainFragment = tgpu.fragmentFn({
- in: { uv: d.vec2f },
- out: d.vec4f,
-})(({ uv }) => {
- 'use gpu';
- /**
- * A transformation matrix that skews the perspective a bit
- * when applied to UV coordinates
- */
- const skewMat = d.mat2x2f(
- d.vec2f(std.cos(angle), std.sin(angle)),
- d.vec2f(-std.sin(angle) * 10 + uv.x * 3, std.cos(angle) * 5),
- );
- const skewedUv = skewMat * uv;
- const tile = tilePattern(skewedUv * tileDensity.$);
- const albedo = std.mix(d.vec3f(0.1), d.vec3f(1), tile);
-
- // Transforming coordinates to simulate perspective squash
- const cuv = d.vec2f(
- uv.x * (std.pow(uv.y * 1.5, 3) + 0.1) * 5,
- std.pow((uv.y * 1.5 + 0.1) * 1.5, 3) * 1,
- );
- // Generating two layers of caustics (large scale, and small scale)
- const c1 =
- caustics(cuv, time.$ * 0.2, /* profile */ d.vec3f(4, 4, 1)) *
- // Tinting
- d.vec3f(0.4, 0.65, 1);
- const c2 =
- caustics(cuv * 2, time.$ * 0.4, /* profile */ d.vec3f(16, 1, 4)) *
- // Tinting
- d.vec3f(0.18, 0.3, 0.5);
-
- // -- BLEND --
-
- const blendCoord = d.vec3f(uv * d.vec2f(5, 10), time.$ * 0.2 + 5);
- // A smooth blending factor, so that caustics only appear at certain spots
- const blend = std.saturate(perlin3d.sample(blendCoord) + 0.3);
-
- // -- FOG --
-
- const noFogColor = albedo * std.mix(ambientColor, c1 + c2, blend);
- // Fog blending factor, based on the height of the pixels
- const fog = std.min(uv.y ** 0.5 * 1.2, 1);
-
- // -- GOD RAYS --
-
- const godRayUv = rotateXY(-0.3) * uv * d.vec2f(15, 3);
- const godRayFactor = uv.y;
- const godRay1 =
- (perlin3d.sample(d.vec3f(godRayUv, time.$ * 0.5)) + 1) *
- // Tinting
- d.vec3f(0.18, 0.3, 0.5) *
- godRayFactor;
- const godRay2 =
- (perlin3d.sample(d.vec3f(godRayUv * 2, time.$ * 0.3)) + 1) *
- // Tinting
- d.vec3f(0.18, 0.3, 0.5) *
- godRayFactor *
- 0.4;
- const godRays = godRay1 + godRay2;
-
- return d.vec4f(std.mix(noFogColor, fogColor, fog) + godRays, 1);
-});
-
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
-
-const pipeline = root.createRenderPipeline({
- vertex: mainVertex,
- fragment: mainFragment,
-});
-
-let isRunning = true;
-
-function draw(timestamp: number) {
- if (!isRunning) return;
-
- time.write((timestamp * 0.001) % 1000);
-
- pipeline.withColorAttachment({ view: context }).draw(3);
-
- requestAnimationFrame(draw);
-}
-requestAnimationFrame(draw);
+const scene = await setupScene(root, context);
// #region Example controls and cleanup
@@ -155,13 +17,13 @@ export const controls = defineControls({
max: 20,
step: 1,
onSliderChange: (density) => {
- tileDensity.write(density);
+ scene.tileDensity = density;
},
},
});
export function onCleanup() {
- isRunning = false;
+ scene.onCleanup();
root.destroy();
}
diff --git a/apps/typegpu-docs/src/examples/rendering/caustics/scene.ts b/apps/typegpu-docs/src/examples/rendering/caustics/scene.ts
new file mode 100644
index 0000000000..33266135eb
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/caustics/scene.ts
@@ -0,0 +1,153 @@
+import { perlin3d } from '@typegpu/noise';
+import tgpu, { d, std, type TgpuRoot } from 'typegpu';
+
+const mainVertex = tgpu.vertexFn({
+ in: { vertexIndex: d.builtin.vertexIndex },
+ out: { pos: d.builtin.position, uv: d.vec2f },
+})(({ vertexIndex }) => {
+ const pos = [d.vec2f(0, 0.8), d.vec2f(-0.8, -0.8), d.vec2f(0.8, -0.8)];
+ const uv = [d.vec2f(0.5, 1), d.vec2f(0, 0), d.vec2f(1, 0)];
+
+ return {
+ pos: d.vec4f(pos[vertexIndex], 0, 1),
+ uv: uv[vertexIndex],
+ };
+});
+
+/**
+ * Given a coordinate, it returns a grayscale floor tile pattern at that
+ * location.
+ */
+const tilePattern = (uv: d.v2f): number => {
+ 'use gpu';
+ const tiledUv = std.fract(uv);
+ const proximity = std.abs(tiledUv * 2 - 1);
+ const maxProximity = std.max(proximity.x, proximity.y);
+ return std.saturate((1 - maxProximity) ** 0.6 * 5);
+};
+
+const caustics = (uv: d.v2f, time: number, profile: d.v3f): d.v3f => {
+ 'use gpu';
+ const distortion = perlin3d.sample(d.vec3f(uv * 0.5, time * 0.2));
+ // Distorting UV coordinates
+ const uv2 = uv + distortion;
+ const noise = std.abs(perlin3d.sample(d.vec3f(uv2 * 5, time)));
+ return std.pow(d.vec3f(1 - noise), profile);
+};
+
+/**
+ * Returns a transformation matrix that represents an `angle` rotation
+ * in the XY plane (around the imaginary Z axis)
+ */
+const rotateXY = (angle: number): d.m2x2f => {
+ 'use gpu';
+ return d.mat2x2f(
+ /* right */ d.vec2f(std.cos(angle), std.sin(angle)),
+ /* up */ d.vec2f(-std.sin(angle), std.cos(angle)),
+ );
+};
+
+/** Seconds passed since the start of the example, wrapped to the range [0, 1000) */
+const angle = 0.2;
+/** The scene fades into this color at a distance */
+const fogColor = d.vec3f(0.05, 0.2, 0.7);
+/** The ambient light color */
+const ambientColor = d.vec3f(0.2, 0.5, 1);
+
+export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
+ /** Seconds passed since the start of the example, wrapped to the range [0, 1000) */
+ const time = root.createUniform(d.f32);
+ /** The bigger the number, the denser the pool tile texture is */
+ const tileDensity = root.createUniform(d.f32, 10);
+
+ const mainFragment = tgpu.fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+ })(({ uv }) => {
+ 'use gpu';
+ /**
+ * A transformation matrix that skews the perspective a bit
+ * when applied to UV coordinates
+ */
+ const skewMat = d.mat2x2f(
+ d.vec2f(std.cos(angle), std.sin(angle)),
+ d.vec2f(-std.sin(angle) * 10 + uv.x * 3, std.cos(angle) * 5),
+ );
+ const skewedUv = skewMat * uv;
+ const tile = tilePattern(skewedUv * tileDensity.$);
+ const albedo = std.mix(d.vec3f(0.1), d.vec3f(1), tile);
+
+ // Transforming coordinates to simulate perspective squash
+ const cuv = d.vec2f(
+ uv.x * (std.pow(uv.y * 1.5, 3) + 0.1) * 5,
+ std.pow((uv.y * 1.5 + 0.1) * 1.5, 3) * 1,
+ );
+ // Generating two layers of caustics (large scale, and small scale)
+ const c1 =
+ caustics(cuv, time.$ * 0.2, /* profile */ d.vec3f(4, 4, 1)) *
+ // Tinting
+ d.vec3f(0.4, 0.65, 1);
+ const c2 =
+ caustics(cuv * 2, time.$ * 0.4, /* profile */ d.vec3f(16, 1, 4)) *
+ // Tinting
+ d.vec3f(0.18, 0.3, 0.5);
+
+ // -- BLEND --
+
+ const blendCoord = d.vec3f(uv * d.vec2f(5, 10), time.$ * 0.2 + 5);
+ // A smooth blending factor, so that caustics only appear at certain spots
+ const blend = std.saturate(perlin3d.sample(blendCoord) + 0.3);
+
+ // -- FOG --
+
+ const noFogColor = albedo * std.mix(ambientColor, c1 + c2, blend);
+ // Fog blending factor, based on the height of the pixels
+ const fog = std.min(uv.y ** 0.5 * 1.2, 1);
+
+ // -- GOD RAYS --
+
+ const godRayUv = rotateXY(-0.3) * uv * d.vec2f(15, 3);
+ const godRayFactor = uv.y;
+ const godRay1 =
+ (perlin3d.sample(d.vec3f(godRayUv, time.$ * 0.5)) + 1) *
+ // Tinting
+ d.vec3f(0.18, 0.3, 0.5) *
+ godRayFactor;
+ const godRay2 =
+ (perlin3d.sample(d.vec3f(godRayUv * 2, time.$ * 0.3)) + 1) *
+ // Tinting
+ d.vec3f(0.18, 0.3, 0.5) *
+ godRayFactor *
+ 0.4;
+ const godRays = godRay1 + godRay2;
+
+ return d.vec4f(std.mix(noFogColor, fogColor, fog) + godRays, 1);
+ });
+
+ const pipeline = root.createRenderPipeline({
+ vertex: mainVertex,
+ fragment: mainFragment,
+ });
+
+ let isRunning = true;
+
+ function draw(timestamp: number) {
+ if (!isRunning) return;
+
+ time.write((timestamp * 0.001) % 1000);
+
+ pipeline.withColorAttachment({ view: context }).draw(3);
+
+ requestAnimationFrame(draw);
+ }
+ requestAnimationFrame(draw);
+
+ return {
+ set tileDensity(density: number) {
+ tileDensity.write(density);
+ },
+ onCleanup() {
+ isRunning = false;
+ },
+ };
+}
diff --git a/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts b/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts
index b45924d6cb..cfe61bf002 100644
--- a/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts
+++ b/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts
@@ -1,11 +1,8 @@
-import tgpu, { d, std } from 'typegpu';
-import * as m from 'wgpu-matrix';
-import { type CubemapNames, cubeVertices, loadCubemap } from './cubemap.ts';
-import { Camera, CubeVertex, DirectionalLight, Material, Vertex } from './dataTypes.ts';
-import { IcosphereGenerator } from './icosphere.ts';
-import { defineControls } from '../../common/defineControls.ts';
+import tgpu, { d } from 'typegpu';
-// Initialization
+import { type CubemapNames } from './cubemap.ts';
+import { defineControls } from '../../common/defineControls.ts';
+import { setupScene } from './scene.ts';
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
@@ -23,407 +20,12 @@ const device = await adapter.requestDevice({
});
const root = tgpu.initFromDevice({ device });
-// Canvas Setup
-
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
-let exampleDestroyed = false;
-
-// Geometry & Material Setup
-
-let smoothNormals = false;
-let subdivisions = 2;
-const materialProps = {
- shininess: 32,
- reflectivity: 0.7,
- ambient: d.vec3f(0.1, 0.1, 0.1),
- diffuse: d.vec3f(0.3, 0.3, 0.3),
- specular: d.vec3f(0.8, 0.8, 0.8),
-};
-
-const icosphereGenerator = new IcosphereGenerator(root, maxSize);
-let vertexBuffer = icosphereGenerator.createIcosphere(subdivisions, smoothNormals);
-const cubeVertexBuffer = root
- .createBuffer(d.arrayOf(CubeVertex, cubeVertices.length), cubeVertices)
- .$usage('vertex');
-
-// Camera Setup
-
-const cameraInitialPos = d.vec3f(0, 1, 5);
-const cameraBuffer = root
- .createBuffer(Camera, {
- view: m.mat4.lookAt(cameraInitialPos, [0, 0, 0], [0, 1, 0], d.mat4x4f()),
- projection: m.mat4.perspective(
- Math.PI / 4,
- canvas.width / canvas.height,
- 0.1,
- 10000,
- d.mat4x4f(),
- ),
- position: d.vec4f(cameraInitialPos, 1),
- })
- .$usage('uniform');
-
-// Light & Material Buffers
-
-const lightBuffer = root
- .createBuffer(DirectionalLight, {
- direction: d.vec3f(1, 1, 5),
- color: d.vec3f(1, 1, 1),
- intensity: 1,
- })
- .$usage('uniform');
-
-const materialBuffer = root.createBuffer(Material, materialProps).$usage('uniform');
-
-// Textures & Samplers
-
-let chosenCubemap: CubemapNames = 'city';
-const size = 2048;
-const texture = root
- .createTexture({
- dimension: '2d',
- size: [size, size, 6],
- format: 'rgba8unorm',
- })
- .$usage('sampled', 'render');
-await loadCubemap(texture, chosenCubemap);
-
-const cubemap = texture.createView(d.textureCube(d.f32));
-const sampler = root.createSampler({
- magFilter: 'linear',
- minFilter: 'linear',
-});
-
-// Bind Groups & Layouts
-
-const renderLayout = tgpu.bindGroupLayout({
- camera: { uniform: Camera },
- light: { uniform: DirectionalLight },
- material: { uniform: Material },
-});
-
-const renderBindGroup = root.createBindGroup(renderLayout, {
- camera: cameraBuffer,
- light: lightBuffer,
- material: materialBuffer,
-});
-
-const textureLayout = tgpu.bindGroupLayout({
- cubemap: { texture: d.textureCube(d.f32) },
- texSampler: { sampler: 'filtering' },
-});
-
-const textureBindGroup = root.createBindGroup(textureLayout, {
- cubemap,
- texSampler: sampler,
-});
-
-const vertexLayout = tgpu.vertexLayout(d.disarrayOf(Vertex));
-const cubeVertexLayout = tgpu.vertexLayout((n: number) => d.arrayOf(CubeVertex, n));
-
-// Shader Functions
-
-const vertexFn = tgpu.vertexFn({
- in: {
- position: d.vec4f,
- normal: d.vec4f,
- },
- out: {
- pos: d.builtin.position,
- normal: d.vec4f,
- worldPos: d.vec4f,
- },
-})((input) => ({
- pos: std.mul(
- renderLayout.$.camera.projection,
- std.mul(renderLayout.$.camera.view, input.position),
- ),
- normal: input.normal,
- worldPos: input.position,
-}));
-
-const fragmentFn = tgpu.fragmentFn({
- in: {
- normal: d.vec4f,
- worldPos: d.vec4f,
- },
- out: d.vec4f,
-})((input) => {
- const normalizedNormal = std.normalize(input.normal.xyz);
- const normalizedLightDir = std.normalize(renderLayout.$.light.direction);
-
- const ambientLight = renderLayout.$.material.ambient
- .mul(renderLayout.$.light.color)
- .mul(renderLayout.$.light.intensity);
-
- const diffuseFactor = std.max(std.dot(normalizedNormal, normalizedLightDir), 0);
- const diffuseLight = renderLayout.$.material.diffuse
- .mul(renderLayout.$.light.color)
- .mul(renderLayout.$.light.intensity)
- .mul(diffuseFactor);
-
- const viewDirection = std.normalize(renderLayout.$.camera.position.xyz.sub(input.worldPos.xyz));
- const reflectionDirection = std.reflect(std.neg(normalizedLightDir), normalizedNormal);
-
- const specularFactor =
- std.max(std.dot(viewDirection, reflectionDirection), 0) ** renderLayout.$.material.shininess;
- const specularLight = renderLayout.$.material.specular
- .mul(renderLayout.$.light.color)
- .mul(renderLayout.$.light.intensity)
- .mul(specularFactor);
-
- const reflectionVector = std.reflect(std.neg(viewDirection), normalizedNormal);
- const environmentColor = std.textureSample(
- textureLayout.$.cubemap,
- textureLayout.$.texSampler,
- reflectionVector,
- );
-
- const directLighting = ambientLight.add(diffuseLight.add(specularLight));
-
- const finalColor = std.mix(
- directLighting,
- environmentColor.rgb,
- renderLayout.$.material.reflectivity,
- );
-
- return d.vec4f(finalColor, 1.0);
-});
-
-const cubeVertexFn = tgpu.vertexFn({
- in: {
- position: d.vec3f,
- uv: d.vec2f,
- },
- out: {
- pos: d.builtin.position,
- texCoord: d.vec3f,
- },
-})((input) => {
- const viewPos = renderLayout.$.camera.view.mul(d.vec4f(input.position.xyz, 0)).xyz;
-
- return {
- pos: renderLayout.$.camera.projection.mul(d.vec4f(viewPos, 1)),
- texCoord: input.position.xyz,
- };
-});
-
-const cubeFragmentFn = tgpu.fragmentFn({
- in: { texCoord: d.vec3f },
- out: d.vec4f,
-})((input) => {
- return std.textureSample(
- textureLayout.$.cubemap,
- textureLayout.$.texSampler,
- std.normalize(input.texCoord),
- );
-});
-
-// Pipeline Setup
-
-const cubePipeline = root.createRenderPipeline({
- attribs: cubeVertexLayout.attrib,
- vertex: cubeVertexFn,
- fragment: cubeFragmentFn,
- primitive: { cullMode: 'front' },
-});
-
-const pipeline = root.createRenderPipeline({
- attribs: vertexLayout.attrib,
- vertex: vertexFn,
- fragment: fragmentFn,
- primitive: { cullMode: 'back' },
-});
-
-// Render Functions
-
-function render() {
- cubePipeline
- .withColorAttachment({
- view: context,
- clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1 },
- })
- .with(cubeVertexLayout, cubeVertexBuffer)
- .with(renderBindGroup)
- .with(textureBindGroup)
- .draw(cubeVertices.length);
-
- pipeline
- .withColorAttachment({
- view: context,
- clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1 },
- loadOp: 'load',
- })
- .with(vertexLayout, vertexBuffer)
- .with(renderBindGroup)
- .with(textureBindGroup)
- .draw(vertexBuffer.dataType.elementCount);
-}
-
-function loop() {
- if (exampleDestroyed) {
- return;
- }
- render();
- requestAnimationFrame(loop);
-}
-
-loop();
+const scene = await setupScene(root, context);
// #region Example controls and cleanup
-const resizeObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- const dpr = window.devicePixelRatio;
- const width = entry.contentRect.width;
- const height = entry.contentRect.height;
- canvas.width = width * dpr;
- canvas.height = height * dpr;
- const newProj = m.mat4.perspective(
- Math.PI / 4,
- canvas.width / canvas.height,
- 0.1,
- 10000,
- d.mat4x4f(),
- );
- cameraBuffer.patch({ projection: newProj });
- }
-});
-resizeObserver.observe(canvas);
-
-// Variables for mouse interaction.
-let isDragging = false;
-let prevX = 0;
-let prevY = 0;
-let lastPinchDist = 0;
-let orbitRadius = std.length(cameraInitialPos);
-
-// Yaw and pitch angles facing the origin.
-let orbitYaw = Math.atan2(cameraInitialPos.x, cameraInitialPos.z);
-let orbitPitch = Math.asin(cameraInitialPos.y / orbitRadius);
-
-function updateCameraPosition() {
- const newCamX = orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch);
- const newCamY = orbitRadius * Math.sin(orbitPitch);
- const newCamZ = orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch);
- const newCameraPos = d.vec4f(newCamX, newCamY, newCamZ, 1);
-
- const newView = m.mat4.lookAt(newCameraPos, d.vec3f(0, 0, 0), d.vec3f(0, 1, 0), d.mat4x4f());
- cameraBuffer.patch({ view: newView, position: newCameraPos });
-}
-
-function updateCameraOrbit(dx: number, dy: number) {
- orbitYaw += -dx * 0.005;
- orbitPitch = std.clamp(orbitPitch + dy * 0.005, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
- updateCameraPosition();
-}
-
-function zoomCamera(delta: number) {
- orbitRadius = std.clamp(orbitRadius + delta, 3, 100);
- updateCameraPosition();
-}
-
-canvas.addEventListener(
- 'wheel',
- (e: WheelEvent) => {
- e.preventDefault();
- zoomCamera(e.deltaY * 0.05);
- },
- { passive: false },
-);
-
-canvas.addEventListener('mousedown', (event) => {
- isDragging = true;
- prevX = event.clientX;
- prevY = event.clientY;
-});
-
-canvas.addEventListener(
- 'touchstart',
- (e) => {
- e.preventDefault();
- if (e.touches.length === 1) {
- isDragging = true;
- prevX = e.touches[0].clientX;
- prevY = e.touches[0].clientY;
- } else if (e.touches.length === 2) {
- isDragging = false;
- const dx = e.touches[0].clientX - e.touches[1].clientX;
- const dy = e.touches[0].clientY - e.touches[1].clientY;
- lastPinchDist = Math.sqrt(dx * dx + dy * dy);
- }
- },
- { passive: false },
-);
-
-const mouseUpEventListener = () => {
- isDragging = false;
-};
-window.addEventListener('mouseup', mouseUpEventListener);
-
-const touchEndEventListener = (e: TouchEvent) => {
- if (e.touches.length === 1) {
- isDragging = true;
- prevX = e.touches[0].clientX;
- prevY = e.touches[0].clientY;
- } else {
- isDragging = false;
- }
-};
-window.addEventListener('touchend', touchEndEventListener);
-
-const mouseMoveEventListener = (event: MouseEvent) => {
- const dx = event.clientX - prevX;
- const dy = event.clientY - prevY;
- prevX = event.clientX;
- prevY = event.clientY;
-
- if (isDragging) {
- updateCameraOrbit(dx, dy);
- }
-};
-window.addEventListener('mousemove', mouseMoveEventListener);
-
-const touchMoveEventListener = (e: TouchEvent) => {
- if (e.touches.length === 1 && isDragging) {
- e.preventDefault();
- const dx = e.touches[0].clientX - prevX;
- const dy = e.touches[0].clientY - prevY;
- prevX = e.touches[0].clientX;
- prevY = e.touches[0].clientY;
- updateCameraOrbit(dx, dy);
- }
-};
-window.addEventListener('touchmove', touchMoveEventListener, {
- passive: false,
-});
-
-canvas.addEventListener(
- 'touchmove',
- (e) => {
- if (e.touches.length === 2) {
- e.preventDefault();
- const dx = e.touches[0].clientX - e.touches[1].clientX;
- const dy = e.touches[0].clientY - e.touches[1].clientY;
- const pinchDist = Math.sqrt(dx * dx + dy * dy);
- zoomCamera((lastPinchDist - pinchDist) * 0.05);
- lastPinchDist = pinchDist;
- }
- },
- { passive: false },
-);
-
-function hideHelp() {
- const helpElem = document.getElementById('help');
- if (helpElem) {
- helpElem.style.opacity = '0';
- }
-}
-for (const eventName of ['click', 'keydown', 'wheel', 'touchstart']) {
- canvas.addEventListener(eventName, hideHelp, { once: true, passive: true });
-}
-
export const controls = defineControls({
subdivisions: {
initial: 2,
@@ -431,76 +33,62 @@ export const controls = defineControls({
max: 10,
step: 1,
onSliderChange(value) {
- subdivisions = value;
- vertexBuffer = icosphereGenerator.createIcosphere(subdivisions, smoothNormals);
+ scene.subdivisions = value;
},
},
'smooth normals': {
initial: false,
onToggleChange: (value) => {
- smoothNormals = value;
- vertexBuffer = icosphereGenerator.createIcosphere(subdivisions, smoothNormals);
+ scene.smoothNormals = value;
},
},
'cubemap texture': {
- initial: chosenCubemap,
+ initial: 'city',
options: ['city', 'campsite', 'beach', 'chapel'],
onSelectChange: async (value) => {
- chosenCubemap = value;
- await loadCubemap(texture, chosenCubemap);
+ scene.cubemapTexture = value as CubemapNames;
},
},
'ambient color': {
- initial: materialProps.ambient,
+ initial: d.vec3f(0.1, 0.1, 0.1),
onColorChange: (value) => {
- materialProps.ambient = value;
- materialBuffer.patch({ ambient: materialProps.ambient });
+ scene.ambientColor = value;
},
},
'diffuse color': {
- initial: materialProps.diffuse,
+ initial: d.vec3f(0.3, 0.3, 0.3),
onColorChange: (value) => {
- materialProps.diffuse = value;
- materialBuffer.patch({ diffuse: materialProps.diffuse });
+ scene.diffuseColor = value;
},
},
'specular color': {
- initial: materialProps.specular,
+ initial: d.vec3f(0.8, 0.8, 0.8),
onColorChange: (value) => {
- materialProps.specular = value;
- materialBuffer.patch({ specular: materialProps.specular });
+ scene.specularColor = value;
},
},
shininess: {
- initial: materialProps.shininess,
+ initial: 32,
min: 1,
max: 128,
step: 1,
onSliderChange: (value) => {
- materialProps.shininess = value;
- materialBuffer.patch({ shininess: value });
+ scene.shininess = value;
},
},
reflectivity: {
- initial: materialProps.reflectivity,
+ initial: 0.7,
min: 0,
max: 1,
step: 0.1,
onSliderChange: (value) => {
- materialProps.reflectivity = value;
- materialBuffer.patch({ reflectivity: value });
+ scene.reflectivity = value;
},
},
});
export function onCleanup() {
- exampleDestroyed = true;
- window.removeEventListener('mouseup', mouseUpEventListener);
- window.removeEventListener('mousemove', mouseMoveEventListener);
- window.removeEventListener('touchmove', touchMoveEventListener);
- window.removeEventListener('touchend', touchEndEventListener);
- resizeObserver.unobserve(canvas);
- icosphereGenerator.destroy();
+ scene.onCleanup();
root.destroy();
}
diff --git a/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/scene.ts b/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/scene.ts
new file mode 100644
index 0000000000..c845d727c2
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/scene.ts
@@ -0,0 +1,457 @@
+import tgpu, { d, std, type TgpuRoot } from 'typegpu';
+import * as m from 'wgpu-matrix';
+import { type CubemapNames, cubeVertices, loadCubemap } from './cubemap.ts';
+import { Camera, CubeVertex, DirectionalLight, Material, Vertex } from './dataTypes.ts';
+import { IcosphereGenerator } from './icosphere.ts';
+
+// Initialization
+
+function hideHelp() {
+ const helpElem = document.getElementById('help');
+ if (helpElem) {
+ helpElem.style.opacity = '0';
+ }
+}
+
+export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
+ const maxBufferSize = root.device.limits.maxStorageBufferBindingSize;
+ const maxStorageBufferBindingSize = root.device.limits.maxStorageBufferBindingSize;
+ const maxSize = Math.min(maxBufferSize, maxStorageBufferBindingSize);
+ const canvas = context.canvas as HTMLCanvasElement;
+ let exampleDestroyed = false;
+
+ // Geometry & Material Setup
+
+ let smoothNormals = false;
+ let subdivisions = 2;
+ const materialProps = {
+ shininess: 32,
+ reflectivity: 0.7,
+ ambient: d.vec3f(0.1, 0.1, 0.1),
+ diffuse: d.vec3f(0.3, 0.3, 0.3),
+ specular: d.vec3f(0.8, 0.8, 0.8),
+ };
+
+ const icosphereGenerator = new IcosphereGenerator(root, maxSize);
+ let vertexBuffer = icosphereGenerator.createIcosphere(subdivisions, smoothNormals);
+ const cubeVertexBuffer = root
+ .createBuffer(d.arrayOf(CubeVertex, cubeVertices.length), cubeVertices)
+ .$usage('vertex');
+
+ // Camera Setup
+
+ const cameraInitialPos = d.vec3f(0, 1, 5);
+ const cameraBuffer = root
+ .createBuffer(Camera, {
+ view: m.mat4.lookAt(cameraInitialPos, [0, 0, 0], [0, 1, 0], d.mat4x4f()),
+ projection: m.mat4.perspective(
+ Math.PI / 4,
+ canvas.width / canvas.height,
+ 0.1,
+ 10000,
+ d.mat4x4f(),
+ ),
+ position: d.vec4f(cameraInitialPos, 1),
+ })
+ .$usage('uniform');
+
+ // Light & Material Buffers
+
+ const lightBuffer = root
+ .createBuffer(DirectionalLight, {
+ direction: d.vec3f(1, 1, 5),
+ color: d.vec3f(1, 1, 1),
+ intensity: 1,
+ })
+ .$usage('uniform');
+
+ const materialBuffer = root.createBuffer(Material, materialProps).$usage('uniform');
+
+ // Textures & Samplers
+
+ let chosenCubemap: CubemapNames = 'city';
+ const size = 2048;
+ const texture = root
+ .createTexture({
+ dimension: '2d',
+ size: [size, size, 6],
+ format: 'rgba8unorm',
+ })
+ .$usage('sampled', 'render');
+ await loadCubemap(texture, chosenCubemap);
+
+ const cubemap = texture.createView(d.textureCube(d.f32));
+ const sampler = root.createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+ });
+
+ // Bind Groups & Layouts
+
+ const renderLayout = tgpu.bindGroupLayout({
+ camera: { uniform: Camera },
+ light: { uniform: DirectionalLight },
+ material: { uniform: Material },
+ });
+
+ const renderBindGroup = root.createBindGroup(renderLayout, {
+ camera: cameraBuffer,
+ light: lightBuffer,
+ material: materialBuffer,
+ });
+
+ const textureLayout = tgpu.bindGroupLayout({
+ cubemap: { texture: d.textureCube(d.f32) },
+ texSampler: { sampler: 'filtering' },
+ });
+
+ const textureBindGroup = root.createBindGroup(textureLayout, {
+ cubemap,
+ texSampler: sampler,
+ });
+
+ const vertexLayout = tgpu.vertexLayout(d.disarrayOf(Vertex));
+ const cubeVertexLayout = tgpu.vertexLayout((n: number) => d.arrayOf(CubeVertex, n));
+
+ // Shader Functions
+
+ const vertexFn = tgpu.vertexFn({
+ in: {
+ position: d.vec4f,
+ normal: d.vec4f,
+ },
+ out: {
+ pos: d.builtin.position,
+ normal: d.vec4f,
+ worldPos: d.vec4f,
+ },
+ })((input) => ({
+ pos: std.mul(
+ renderLayout.$.camera.projection,
+ std.mul(renderLayout.$.camera.view, input.position),
+ ),
+ normal: input.normal,
+ worldPos: input.position,
+ }));
+
+ const fragmentFn = tgpu.fragmentFn({
+ in: {
+ normal: d.vec4f,
+ worldPos: d.vec4f,
+ },
+ out: d.vec4f,
+ })((input) => {
+ const normalizedNormal = std.normalize(input.normal.xyz);
+ const normalizedLightDir = std.normalize(renderLayout.$.light.direction);
+
+ const ambientLight = renderLayout.$.material.ambient
+ .mul(renderLayout.$.light.color)
+ .mul(renderLayout.$.light.intensity);
+
+ const diffuseFactor = std.max(std.dot(normalizedNormal, normalizedLightDir), 0);
+ const diffuseLight = renderLayout.$.material.diffuse
+ .mul(renderLayout.$.light.color)
+ .mul(renderLayout.$.light.intensity)
+ .mul(diffuseFactor);
+
+ const viewDirection = std.normalize(renderLayout.$.camera.position.xyz.sub(input.worldPos.xyz));
+ const reflectionDirection = std.reflect(std.neg(normalizedLightDir), normalizedNormal);
+
+ const specularFactor =
+ std.max(std.dot(viewDirection, reflectionDirection), 0) ** renderLayout.$.material.shininess;
+ const specularLight = renderLayout.$.material.specular
+ .mul(renderLayout.$.light.color)
+ .mul(renderLayout.$.light.intensity)
+ .mul(specularFactor);
+
+ const reflectionVector = std.reflect(std.neg(viewDirection), normalizedNormal);
+ const environmentColor = std.textureSample(
+ textureLayout.$.cubemap,
+ textureLayout.$.texSampler,
+ reflectionVector,
+ );
+
+ const directLighting = ambientLight.add(diffuseLight.add(specularLight));
+
+ const finalColor = std.mix(
+ directLighting,
+ environmentColor.rgb,
+ renderLayout.$.material.reflectivity,
+ );
+
+ return d.vec4f(finalColor, 1.0);
+ });
+
+ const cubeVertexFn = tgpu.vertexFn({
+ in: {
+ position: d.vec3f,
+ uv: d.vec2f,
+ },
+ out: {
+ pos: d.builtin.position,
+ texCoord: d.vec3f,
+ },
+ })((input) => {
+ const viewPos = renderLayout.$.camera.view.mul(d.vec4f(input.position.xyz, 0)).xyz;
+
+ return {
+ pos: renderLayout.$.camera.projection.mul(d.vec4f(viewPos, 1)),
+ texCoord: input.position.xyz,
+ };
+ });
+
+ const cubeFragmentFn = tgpu.fragmentFn({
+ in: { texCoord: d.vec3f },
+ out: d.vec4f,
+ })((input) => {
+ return std.textureSample(
+ textureLayout.$.cubemap,
+ textureLayout.$.texSampler,
+ std.normalize(input.texCoord),
+ );
+ });
+
+ // Pipeline Setup
+
+ const cubePipeline = root.createRenderPipeline({
+ attribs: cubeVertexLayout.attrib,
+ vertex: cubeVertexFn,
+ fragment: cubeFragmentFn,
+ primitive: { cullMode: 'front' },
+ });
+
+ const pipeline = root.createRenderPipeline({
+ attribs: vertexLayout.attrib,
+ vertex: vertexFn,
+ fragment: fragmentFn,
+ primitive: { cullMode: 'back' },
+ });
+
+ // Render Functions
+
+ function render() {
+ cubePipeline
+ .withColorAttachment({
+ view: context,
+ clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1 },
+ })
+ .with(cubeVertexLayout, cubeVertexBuffer)
+ .with(renderBindGroup)
+ .with(textureBindGroup)
+ .draw(cubeVertices.length);
+
+ pipeline
+ .withColorAttachment({
+ view: context,
+ clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1 },
+ loadOp: 'load',
+ })
+ .with(vertexLayout, vertexBuffer)
+ .with(renderBindGroup)
+ .with(textureBindGroup)
+ .draw(vertexBuffer.dataType.elementCount);
+ }
+
+ function loop() {
+ if (exampleDestroyed) {
+ return;
+ }
+ render();
+ requestAnimationFrame(loop);
+ }
+
+ loop();
+
+ // #region Example controls and cleanup
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const dpr = window.devicePixelRatio;
+ const width = entry.contentRect.width;
+ const height = entry.contentRect.height;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ const newProj = m.mat4.perspective(
+ Math.PI / 4,
+ canvas.width / canvas.height,
+ 0.1,
+ 10000,
+ d.mat4x4f(),
+ );
+ cameraBuffer.patch({ projection: newProj });
+ }
+ });
+ resizeObserver.observe(canvas);
+
+ // Variables for mouse interaction.
+ let isDragging = false;
+ let prevX = 0;
+ let prevY = 0;
+ let lastPinchDist = 0;
+ let orbitRadius = std.length(cameraInitialPos);
+
+ // Yaw and pitch angles facing the origin.
+ let orbitYaw = Math.atan2(cameraInitialPos.x, cameraInitialPos.z);
+ let orbitPitch = Math.asin(cameraInitialPos.y / orbitRadius);
+
+ function updateCameraPosition() {
+ const newCamX = orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch);
+ const newCamY = orbitRadius * Math.sin(orbitPitch);
+ const newCamZ = orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch);
+ const newCameraPos = d.vec4f(newCamX, newCamY, newCamZ, 1);
+
+ const newView = m.mat4.lookAt(newCameraPos, d.vec3f(0, 0, 0), d.vec3f(0, 1, 0), d.mat4x4f());
+ cameraBuffer.patch({ view: newView, position: newCameraPos });
+ }
+
+ function updateCameraOrbit(dx: number, dy: number) {
+ orbitYaw += -dx * 0.005;
+ orbitPitch = std.clamp(orbitPitch + dy * 0.005, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
+ updateCameraPosition();
+ }
+
+ function zoomCamera(delta: number) {
+ orbitRadius = std.clamp(orbitRadius + delta, 3, 100);
+ updateCameraPosition();
+ }
+
+ canvas.addEventListener(
+ 'wheel',
+ (e: WheelEvent) => {
+ e.preventDefault();
+ zoomCamera(e.deltaY * 0.05);
+ },
+ { passive: false },
+ );
+
+ canvas.addEventListener('mousedown', (event) => {
+ isDragging = true;
+ prevX = event.clientX;
+ prevY = event.clientY;
+ });
+
+ canvas.addEventListener(
+ 'touchstart',
+ (e) => {
+ e.preventDefault();
+ if (e.touches.length === 1) {
+ isDragging = true;
+ prevX = e.touches[0].clientX;
+ prevY = e.touches[0].clientY;
+ } else if (e.touches.length === 2) {
+ isDragging = false;
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
+ lastPinchDist = Math.sqrt(dx * dx + dy * dy);
+ }
+ },
+ { passive: false },
+ );
+
+ const mouseUpEventListener = () => {
+ isDragging = false;
+ };
+ window.addEventListener('mouseup', mouseUpEventListener);
+
+ const touchEndEventListener = (e: TouchEvent) => {
+ if (e.touches.length === 1) {
+ isDragging = true;
+ prevX = e.touches[0].clientX;
+ prevY = e.touches[0].clientY;
+ } else {
+ isDragging = false;
+ }
+ };
+ window.addEventListener('touchend', touchEndEventListener);
+
+ const mouseMoveEventListener = (event: MouseEvent) => {
+ const dx = event.clientX - prevX;
+ const dy = event.clientY - prevY;
+ prevX = event.clientX;
+ prevY = event.clientY;
+
+ if (isDragging) {
+ updateCameraOrbit(dx, dy);
+ }
+ };
+ window.addEventListener('mousemove', mouseMoveEventListener);
+
+ const touchMoveEventListener = (e: TouchEvent) => {
+ if (e.touches.length === 1 && isDragging) {
+ e.preventDefault();
+ const dx = e.touches[0].clientX - prevX;
+ const dy = e.touches[0].clientY - prevY;
+ prevX = e.touches[0].clientX;
+ prevY = e.touches[0].clientY;
+ updateCameraOrbit(dx, dy);
+ }
+ };
+ window.addEventListener('touchmove', touchMoveEventListener, {
+ passive: false,
+ });
+
+ canvas.addEventListener(
+ 'touchmove',
+ (e) => {
+ if (e.touches.length === 2) {
+ e.preventDefault();
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
+ const pinchDist = Math.sqrt(dx * dx + dy * dy);
+ zoomCamera((lastPinchDist - pinchDist) * 0.05);
+ lastPinchDist = pinchDist;
+ }
+ },
+ { passive: false },
+ );
+
+ for (const eventName of ['click', 'keydown', 'wheel', 'touchstart']) {
+ canvas.addEventListener(eventName, hideHelp, { once: true, passive: true });
+ }
+
+ return {
+ set subdivisions(value: number) {
+ subdivisions = value;
+ vertexBuffer = icosphereGenerator.createIcosphere(subdivisions, smoothNormals);
+ },
+ set smoothNormals(value: boolean) {
+ smoothNormals = value;
+ vertexBuffer = icosphereGenerator.createIcosphere(subdivisions, smoothNormals);
+ },
+ set cubemapTexture(value: CubemapNames) {
+ chosenCubemap = value;
+ void loadCubemap(texture, chosenCubemap);
+ },
+ set ambientColor(value: d.v3f) {
+ materialProps.ambient = value;
+ materialBuffer.patch({ ambient: materialProps.ambient });
+ },
+ set diffuseColor(value: d.v3f) {
+ materialProps.diffuse = value;
+ materialBuffer.patch({ diffuse: materialProps.diffuse });
+ },
+ set specularColor(value: d.v3f) {
+ materialProps.specular = value;
+ materialBuffer.patch({ specular: materialProps.specular });
+ },
+ set shininess(value: number) {
+ materialProps.shininess = value;
+ materialBuffer.patch({ shininess: value });
+ },
+ set reflectivity(value: number) {
+ materialProps.reflectivity = value;
+ materialBuffer.patch({ reflectivity: value });
+ },
+ onCleanup() {
+ exampleDestroyed = true;
+ window.removeEventListener('mouseup', mouseUpEventListener);
+ window.removeEventListener('mousemove', mouseMoveEventListener);
+ window.removeEventListener('touchmove', touchMoveEventListener);
+ window.removeEventListener('touchend', touchEndEventListener);
+ resizeObserver.unobserve(canvas);
+ icosphereGenerator.destroy();
+ },
+ };
+}
+
+// #endregion
diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts
index b76801d535..6b8ee8bfca 100644
--- a/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts
+++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts
@@ -13,9 +13,6 @@ const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
const scene = await setupScene(root, context);
-const resizeObserver = new ResizeObserver(() => scene.onResize());
-resizeObserver.observe(canvas);
-
// #region Example controls and cleanup
let attributionDismissed = false;
@@ -42,7 +39,6 @@ export const controls = defineControls({
void scene.computeOptimalQuality().then((scale) => {
scene.qualityScale = scale;
console.log(`Auto-selected quality scale: ${scale.toFixed(2)}`);
- scene.onResize();
});
return;
}
@@ -56,7 +52,6 @@ export const controls = defineControls({
};
scene.qualityScale = qualityMap[value] || 0.5;
- scene.onResize();
},
},
'Light dir': {
@@ -87,7 +82,6 @@ export const controls = defineControls({
export function onCleanup() {
scene.onCleanup();
- resizeObserver.disconnect();
root.destroy();
}
diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/scene.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/scene.ts
index 6bf3b8cb80..8563ff240a 100644
--- a/apps/typegpu-docs/src/examples/rendering/jelly-slider/scene.ts
+++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/scene.ts
@@ -660,6 +660,7 @@ export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
const digitsTexture = digitsProvider.digitTextureAtlas.createView(d.texture2dArray(d.f32));
let qualityScale = 0.5;
+ let [prevCanvasWidth, prevCanvasHeight] = [canvas.width, canvas.height];
let [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
let textures = createTextures(root, width, height);
@@ -745,8 +746,24 @@ export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
}
let bindGroups = createBindGroups();
+ function onResize() {
+ [prevCanvasWidth, prevCanvasHeight] = [canvas.width, canvas.height];
+ [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
+ camera.updateProjection(Math.PI / 4, width, height);
+ textures = createTextures(root, width, height);
+ backgroundTexture = createBackgroundTexture(root, width, height);
+ taaResolver.resize(width, height);
+ frameCount = 0;
+
+ bindGroups = createBindGroups();
+ }
+
let animationFrameHandle: number;
function render(timestamp: number) {
+ if (canvas.width !== prevCanvasWidth || canvas.height !== prevCanvasHeight) {
+ onResize();
+ }
+
frameCount++;
camera.jitter();
const deltaTime = Math.min(
@@ -793,6 +810,7 @@ export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
},
set qualityScale(v: number) {
qualityScale = v;
+ onResize();
},
set lightDirection(v: d.v3f) {
sceneParamsUniform.patch({
@@ -857,16 +875,6 @@ export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
return resolutionScale;
},
- onResize() {
- [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
- camera.updateProjection(Math.PI / 4, width, height);
- textures = createTextures(root, width, height);
- backgroundTexture = createBackgroundTexture(root, width, height);
- taaResolver.resize(width, height);
- frameCount = 0;
-
- bindGroups = createBindGroups();
- },
onCleanup() {
cancelAnimationFrame(animationFrameHandle);
},
diff --git a/apps/typegpu-docs/src/examples/rendering/ray-marching/index.ts b/apps/typegpu-docs/src/examples/rendering/ray-marching/index.ts
index fd87b161d9..238a1a4ab3 100644
--- a/apps/typegpu-docs/src/examples/rendering/ray-marching/index.ts
+++ b/apps/typegpu-docs/src/examples/rendering/ray-marching/index.ts
@@ -1,238 +1,13 @@
-import { sdBoxFrame3d, sdPlane, sdSphere } from '@typegpu/sdf';
-import tgpu, { d, std } from 'typegpu';
+import tgpu from 'typegpu';
+
+import { setupScene } from './scene.ts';
const root = await tgpu.init();
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
-
-const time = root.createUniform(d.f32);
-const resolution = root.createUniform(d.vec2f);
-
-const MAX_STEPS = 1000;
-const MAX_DIST = 30;
-const SURF_DIST = 0.001;
-
-const skyColor = d.vec4f(0.7, 0.8, 0.9, 1);
-
-// Structure to hold both distance and color
-type Shape = d.Infer;
-const Shape = d.struct({
- color: d.vec3f,
- dist: d.f32,
-});
-
-const checkerBoard = (uv: d.v2f): number => {
- 'use gpu';
- const fuv = std.floor(uv);
- return std.abs(fuv.x + fuv.y) % 2;
-};
-
-const smoothShapeUnion = (a: Shape, b: Shape, k: number): Shape => {
- 'use gpu';
- const h = std.max(k - std.abs(a.dist - b.dist), 0) / k;
- const m = h * h;
-
- // Smooth min for distance
- const dist = std.min(a.dist, b.dist) - m * k * (1 / d.f32(4));
-
- // Blend colors based on relative distances and smoothing
- const weight = m + std.select(0, 1 - m, a.dist > b.dist);
- const color = std.mix(a.color, b.color, weight);
-
- return Shape({ dist, color });
-};
-
-const shapeUnion = (a: Shape, b: Shape) => {
- 'use gpu';
- return Shape({
- color: std.select(a.color, b.color, a.dist > b.dist),
- dist: std.min(a.dist, b.dist),
- });
-};
-
-const getMorphingShape = (p: d.v3f, t: number): Shape => {
- 'use gpu';
- // Center position
- const center = d.vec3f(0, 2, 6);
- const localP = std.sub(p, center);
- const rotMatZ = d.mat4x4f.rotationZ(-t);
- const rotMatX = d.mat4x4f.rotationX(-t * 0.6);
- const rotatedP = std.mul(rotMatZ, std.mul(rotMatX, d.vec4f(localP, 1))).xyz;
-
- // Animate shapes
- const boxSize = d.vec3f(0.7);
-
- // Create two spheres that move in a circular pattern
- const sphere1Offset = d.vec3f(std.cos(t * 2) * 0.8, std.sin(t * 3) * 0.3, std.sin(t * 2) * 0.8);
- const sphere2Offset = d.vec3f(
- std.cos(t * 2 + 3.14) * 0.8,
- std.sin(t * 3 + 1.57) * 0.3,
- std.sin(t * 2 + 3.14) * 0.8,
- );
-
- // Calculate distances and assign colors
- const sphere1 = Shape({
- dist: sdSphere(std.sub(localP, sphere1Offset), 0.5),
- color: d.vec3f(0.4, 0.5, 1),
- });
- const sphere2 = Shape({
- dist: sdSphere(std.sub(localP, sphere2Offset), 0.3),
- color: d.vec3f(1, 0.8, 0.2),
- });
- const box = Shape({
- dist: sdBoxFrame3d(rotatedP, boxSize, 0.1),
- color: d.vec3f(1.0, 0.3, 0.3),
- });
-
- // Smoothly blend shapes and colors
- const spheres = smoothShapeUnion(sphere1, sphere2, 0.1);
- return smoothShapeUnion(spheres, box, 0.2);
-};
-
-const getSceneDist = (p: d.v3f): Shape => {
- 'use gpu';
- const shape = getMorphingShape(p, time.$);
- const floor = Shape({
- dist: sdPlane(p, d.vec3f(0, 1, 0), 0),
- color: std.mix(d.vec3f(1), d.vec3f(0.2), checkerBoard(std.mul(p.xz, 2))),
- });
-
- return shapeUnion(shape, floor);
-};
-
-const rayMarch = (ro: d.v3f, rd: d.v3f): Shape => {
- 'use gpu';
- let dO = d.f32(0);
- const result = Shape({
- dist: d.f32(MAX_DIST),
- color: d.vec3f(0, 0, 0),
- });
-
- for (let i = 0; i < MAX_STEPS; i++) {
- const p = ro.add(rd.mul(dO));
- const scene = getSceneDist(p);
- dO += scene.dist;
-
- if (dO > MAX_DIST || scene.dist < SURF_DIST) {
- result.dist = dO;
- result.color = d.vec3f(scene.color);
- break;
- }
- }
-
- return result;
-};
-
-const softShadow = (ro: d.v3f, rd: d.v3f, minT: number, maxT: number, k: number): number => {
- 'use gpu';
- let res = d.f32(1);
- let t = minT;
-
- for (let i = 0; i < 100; i++) {
- if (t >= maxT) break;
- const h = getSceneDist(ro.add(rd.mul(t))).dist;
- if (h < 0.001) return 0;
- res = std.min(res, (k * h) / t);
- t += std.max(h, 0.001);
- }
-
- return res;
-};
-
-const getNormal = (p: d.v3f): d.v3f => {
- 'use gpu';
- const dist = getSceneDist(p).dist;
- const e = 0.01;
-
- const n = d.vec3f(
- getSceneDist(p.add(d.vec3f(e, 0, 0))).dist - dist,
- getSceneDist(p.add(d.vec3f(0, e, 0))).dist - dist,
- getSceneDist(p.add(d.vec3f(0, 0, e))).dist - dist,
- );
-
- return std.normalize(n);
-};
-
-const getOrbitingLightPos = (t: number): d.v3f => {
- 'use gpu';
- const radius = d.f32(3);
- const height = d.f32(6);
- const speed = d.f32(1);
-
- return d.vec3f(std.cos(t * speed) * radius, height + std.sin(t * speed) * radius, 4);
-};
-
-const vertexMain = tgpu.vertexFn({
- in: { idx: d.builtin.vertexIndex },
- out: { pos: d.builtin.position, uv: d.vec2f },
-})(({ idx }) => {
- const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
- const uv = [d.vec2f(0, 0), d.vec2f(2, 0), d.vec2f(0, 2)];
-
- return {
- pos: d.vec4f(pos[idx], 0.0, 1.0),
- uv: uv[idx],
- };
-});
-
-const fragmentMain = tgpu.fragmentFn({
- in: { uv: d.vec2f },
- out: d.vec4f,
-})((input) => {
- const uv = std.sub(std.mul(input.uv, 2), 1);
- uv.x *= resolution.$.x / resolution.$.y;
-
- // Ray origin and direction
- const ro = d.vec3f(0, 2, 3);
- const rd = std.normalize(d.vec3f(uv.x, uv.y, 1));
-
- const march = rayMarch(ro, rd);
-
- const fog = std.pow(std.min(march.dist / MAX_DIST, 1), 0.7);
-
- const p = std.add(ro, std.mul(rd, march.dist));
- const n = getNormal(p);
-
- // Lighting with orbiting light
- const lightPos = getOrbitingLightPos(time.$);
- const l = std.normalize(lightPos.sub(p));
- const diff = std.max(std.dot(n, l), 0);
-
- // Soft shadows
- const shadowRo = p;
- const shadowRd = l;
- const shadowDist = std.length(lightPos.sub(p));
- const shadow = softShadow(shadowRo, shadowRd, 0.1, shadowDist, d.f32(16));
-
- // Combine lighting with shadows and color
- const litColor = march.color.mul(diff);
- const finalColor = std.mix(
- std.mul(litColor, 0.5), // Shadow color
- litColor, // Lit color
- shadow,
- );
-
- return std.mix(d.vec4f(finalColor, 1), skyColor, fog);
-});
-
-const renderPipeline = root.createRenderPipeline({
- vertex: vertexMain,
- fragment: fragmentMain,
-});
-
-let animationFrame: number;
-function run(timestamp: number) {
- time.write((timestamp / 1000) % 1000);
- resolution.write(d.vec2f(canvas.width, canvas.height));
-
- renderPipeline.withColorAttachment({ view: context }).draw(3);
-
- animationFrame = requestAnimationFrame(run);
-}
-
-animationFrame = requestAnimationFrame(run);
+const scene = await setupScene(root, context);
export function onCleanup() {
- cancelAnimationFrame(animationFrame);
+ scene.onCleanup();
root.destroy();
}
diff --git a/apps/typegpu-docs/src/examples/rendering/ray-marching/scene.ts b/apps/typegpu-docs/src/examples/rendering/ray-marching/scene.ts
new file mode 100644
index 0000000000..c7b144c11e
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/ray-marching/scene.ts
@@ -0,0 +1,238 @@
+import { sdBoxFrame3d, sdPlane, sdSphere } from '@typegpu/sdf';
+import tgpu, { d, std, type TgpuRoot } from 'typegpu';
+
+const MAX_STEPS = 1000;
+const MAX_DIST = 30;
+const SURF_DIST = 0.001;
+
+const skyColor = d.vec4f(0.7, 0.8, 0.9, 1);
+
+// Structure to hold both distance and color
+type Shape = d.Infer;
+const Shape = d.struct({
+ color: d.vec3f,
+ dist: d.f32,
+});
+
+const checkerBoard = (uv: d.v2f): number => {
+ 'use gpu';
+ const fuv = std.floor(uv);
+ return std.abs(fuv.x + fuv.y) % 2;
+};
+
+const smoothShapeUnion = (a: Shape, b: Shape, k: number): Shape => {
+ 'use gpu';
+ const h = std.max(k - std.abs(a.dist - b.dist), 0) / k;
+ const m = h * h;
+
+ // Smooth min for distance
+ const dist = std.min(a.dist, b.dist) - m * k * (1 / d.f32(4));
+
+ // Blend colors based on relative distances and smoothing
+ const weight = m + std.select(0, 1 - m, a.dist > b.dist);
+ const color = std.mix(a.color, b.color, weight);
+
+ return Shape({ dist, color });
+};
+
+const shapeUnion = (a: Shape, b: Shape) => {
+ 'use gpu';
+ return Shape({
+ color: std.select(a.color, b.color, a.dist > b.dist),
+ dist: std.min(a.dist, b.dist),
+ });
+};
+
+const getMorphingShape = (p: d.v3f, t: number): Shape => {
+ 'use gpu';
+ // Center position
+ const center = d.vec3f(0, 2, 6);
+ const localP = std.sub(p, center);
+ const rotMatZ = d.mat4x4f.rotationZ(-t);
+ const rotMatX = d.mat4x4f.rotationX(-t * 0.6);
+ const rotatedP = std.mul(rotMatZ, std.mul(rotMatX, d.vec4f(localP, 1))).xyz;
+
+ // Animate shapes
+ const boxSize = d.vec3f(0.7);
+
+ // Create two spheres that move in a circular pattern
+ const sphere1Offset = d.vec3f(std.cos(t * 2) * 0.8, std.sin(t * 3) * 0.3, std.sin(t * 2) * 0.8);
+ const sphere2Offset = d.vec3f(
+ std.cos(t * 2 + 3.14) * 0.8,
+ std.sin(t * 3 + 1.57) * 0.3,
+ std.sin(t * 2 + 3.14) * 0.8,
+ );
+
+ // Calculate distances and assign colors
+ const sphere1 = Shape({
+ dist: sdSphere(std.sub(localP, sphere1Offset), 0.5),
+ color: d.vec3f(0.4, 0.5, 1),
+ });
+ const sphere2 = Shape({
+ dist: sdSphere(std.sub(localP, sphere2Offset), 0.3),
+ color: d.vec3f(1, 0.8, 0.2),
+ });
+ const box = Shape({
+ dist: sdBoxFrame3d(rotatedP, boxSize, 0.1),
+ color: d.vec3f(1.0, 0.3, 0.3),
+ });
+
+ // Smoothly blend shapes and colors
+ const spheres = smoothShapeUnion(sphere1, sphere2, 0.1);
+ return smoothShapeUnion(spheres, box, 0.2);
+};
+
+const getOrbitingLightPos = (t: number): d.v3f => {
+ 'use gpu';
+ const radius = d.f32(3);
+ const height = d.f32(6);
+ const speed = d.f32(1);
+
+ return d.vec3f(std.cos(t * speed) * radius, height + std.sin(t * speed) * radius, 4);
+};
+
+const vertexMain = tgpu.vertexFn({
+ in: { idx: d.builtin.vertexIndex },
+ out: { pos: d.builtin.position, uv: d.vec2f },
+})(({ idx }) => {
+ const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
+ const uv = [d.vec2f(0, 0), d.vec2f(2, 0), d.vec2f(0, 2)];
+
+ return {
+ pos: d.vec4f(pos[idx], 0.0, 1.0),
+ uv: uv[idx],
+ };
+});
+
+export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
+ const canvas = context.canvas as HTMLCanvasElement;
+ const time = root.createUniform(d.f32);
+ const resolution = root.createUniform(d.vec2f);
+
+ const getSceneDist = (p: d.v3f): Shape => {
+ 'use gpu';
+ const shape = getMorphingShape(p, time.$);
+ const floor = Shape({
+ dist: sdPlane(p, d.vec3f(0, 1, 0), 0),
+ color: std.mix(d.vec3f(1), d.vec3f(0.2), checkerBoard(std.mul(p.xz, 2))),
+ });
+
+ return shapeUnion(shape, floor);
+ };
+
+ const rayMarch = (ro: d.v3f, rd: d.v3f): Shape => {
+ 'use gpu';
+ let dO = d.f32(0);
+ const result = Shape({
+ dist: d.f32(MAX_DIST),
+ color: d.vec3f(0, 0, 0),
+ });
+
+ for (let i = 0; i < MAX_STEPS; i++) {
+ const p = ro.add(rd.mul(dO));
+ const scene = getSceneDist(p);
+ dO += scene.dist;
+
+ if (dO > MAX_DIST || scene.dist < SURF_DIST) {
+ result.dist = dO;
+ result.color = d.vec3f(scene.color);
+ break;
+ }
+ }
+
+ return result;
+ };
+
+ const softShadow = (ro: d.v3f, rd: d.v3f, minT: number, maxT: number, k: number): number => {
+ 'use gpu';
+ let res = d.f32(1);
+ let t = minT;
+
+ for (let i = 0; i < 100; i++) {
+ if (t >= maxT) break;
+ const h = getSceneDist(ro.add(rd.mul(t))).dist;
+ if (h < 0.001) return 0;
+ res = std.min(res, (k * h) / t);
+ t += std.max(h, 0.001);
+ }
+
+ return res;
+ };
+
+ const getNormal = (p: d.v3f): d.v3f => {
+ 'use gpu';
+ const dist = getSceneDist(p).dist;
+ const e = 0.01;
+
+ const n = d.vec3f(
+ getSceneDist(p.add(d.vec3f(e, 0, 0))).dist - dist,
+ getSceneDist(p.add(d.vec3f(0, e, 0))).dist - dist,
+ getSceneDist(p.add(d.vec3f(0, 0, e))).dist - dist,
+ );
+
+ return std.normalize(n);
+ };
+
+ const fragmentMain = tgpu.fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+ })((input) => {
+ const uv = std.sub(std.mul(input.uv, 2), 1);
+ uv.x *= resolution.$.x / resolution.$.y;
+
+ // Ray origin and direction
+ const ro = d.vec3f(0, 2, 3);
+ const rd = std.normalize(d.vec3f(uv.x, uv.y, 1));
+
+ const march = rayMarch(ro, rd);
+
+ const fog = std.pow(std.min(march.dist / MAX_DIST, 1), 0.7);
+
+ const p = std.add(ro, std.mul(rd, march.dist));
+ const n = getNormal(p);
+
+ // Lighting with orbiting light
+ const lightPos = getOrbitingLightPos(time.$);
+ const l = std.normalize(lightPos.sub(p));
+ const diff = std.max(std.dot(n, l), 0);
+
+ // Soft shadows
+ const shadowRo = p;
+ const shadowRd = l;
+ const shadowDist = std.length(lightPos.sub(p));
+ const shadow = softShadow(shadowRo, shadowRd, 0.1, shadowDist, d.f32(16));
+
+ // Combine lighting with shadows and color
+ const litColor = march.color.mul(diff);
+ const finalColor = std.mix(
+ std.mul(litColor, 0.5), // Shadow color
+ litColor, // Lit color
+ shadow,
+ );
+
+ return std.mix(d.vec4f(finalColor, 1), skyColor, fog);
+ });
+
+ const renderPipeline = root.createRenderPipeline({
+ vertex: vertexMain,
+ fragment: fragmentMain,
+ });
+
+ let animationFrame: number;
+ function run(timestamp: number) {
+ time.write((timestamp / 1000) % 1000);
+ resolution.write(d.vec2f(canvas.width, canvas.height));
+
+ renderPipeline.withColorAttachment({ view: context }).draw(3);
+
+ animationFrame = requestAnimationFrame(run);
+ }
+
+ animationFrame = requestAnimationFrame(run);
+
+ return {
+ onCleanup() {
+ cancelAnimationFrame(animationFrame);
+ },
+ };
+}
diff --git a/apps/typegpu-docs/src/examples/simple/vaporrave/index.ts b/apps/typegpu-docs/src/examples/simple/vaporrave/index.ts
index 5dabe04333..b6f51f281e 100644
--- a/apps/typegpu-docs/src/examples/simple/vaporrave/index.ts
+++ b/apps/typegpu-docs/src/examples/simple/vaporrave/index.ts
@@ -1,166 +1,18 @@
-import { perlin3d } from '@typegpu/noise';
-import { sdPlane } from '@typegpu/sdf';
-import tgpu, { d, std } from 'typegpu';
+import tgpu from 'typegpu';
import * as c from './constants.ts';
-import { circles, grid } from './floor.ts';
-import { rayUnion } from './helpers.ts';
-import { getSphere } from './sphere.ts';
-import { LightRay, Ray } from './types.ts';
import { defineControls } from '../../common/defineControls.ts';
+import { setupScene } from './scene.ts';
-// == INIT ==
const root = await tgpu.init();
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
-
-// == BUFFERS ==
-const floorAngleUniform = root.createUniform(d.f32);
-const sphereAngleUniform = root.createUniform(d.f32);
-const glowIntensityUniform = root.createUniform(d.f32, c.INITIAL_GLOW_INTENSITY);
-const resolutionUniform = root.createUniform(d.vec2f);
-const sphereColorUniform = root.createUniform(d.vec3f, c.initialSphereColor);
-
-let floorSpeed = 0.1;
-let sphereSpeed = 1;
-
-const floorPatternSlot = tgpu.slot(circles);
-
-// == RAYMARCHING ==
-
-// returns smallest distance to some object in the scene
-const getSceneRay = tgpu.fn(
- [d.vec3f],
- Ray,
-)((p) => {
- const floor = Ray({
- dist: sdPlane(p, c.planeOrthonormal, c.PLANE_OFFSET),
- color: floorPatternSlot.$(p.xz, floorAngleUniform.$),
- });
- const sphere = getSphere(p, sphereColorUniform.$, c.sphereCenter, sphereAngleUniform.$);
-
- return rayUnion(floor, sphere);
-});
-
-const rayMarch = (ro: d.v3f, rd: d.v3f) => {
- 'use gpu';
- let distOrigin = d.f32();
- const result = Ray({
- dist: d.f32(c.MAX_DIST),
- color: d.vec3f(),
- });
-
- let glow = d.vec3f();
-
- for (let i = 0; i < c.MAX_STEPS; i++) {
- const p = rd * distOrigin + ro;
- const scene = getSceneRay(p);
- const sphereDist = getSphere(p, sphereColorUniform.$, c.sphereCenter, sphereAngleUniform.$);
-
- glow += d.vec3f(sphereColorUniform.$) * std.exp(-sphereDist.dist);
-
- distOrigin += scene.dist;
-
- if (distOrigin > c.MAX_DIST) {
- result.dist = c.MAX_DIST;
- break;
- }
-
- if (scene.dist < c.SURF_DIST) {
- result.dist = distOrigin;
- result.color = d.vec3f(scene.color);
- break;
- }
- }
-
- return LightRay({ ray: result, glow });
-};
-
-const vertexMain = tgpu.vertexFn({
- in: { idx: d.builtin.vertexIndex },
- out: { pos: d.builtin.position, uv: d.vec2f },
-})(({ idx }) => {
- const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
- const uv = [d.vec2f(0, 0), d.vec2f(2, 0), d.vec2f(0, 2)];
-
- return {
- pos: d.vec4f(pos[idx], 0.0, 1.0),
- uv: uv[idx],
- };
-});
-
-const fragmentMain = tgpu.fragmentFn({
- in: { uv: d.vec2f },
- out: d.vec4f,
-})((input) => {
- 'use gpu';
- const uv = input.uv * 2 - 1;
- uv.x *= resolutionUniform.$.x / resolutionUniform.$.y;
-
- // ray origin and direction
- const ro = d.vec3f(0, 2, -1);
- const rd = std.normalize(d.vec3f(uv.x, uv.y, 1));
-
- // marching
- const march = rayMarch(ro, rd);
-
- // sky gradient
- const y = rd.y * march.ray.dist + ro.y - 2; // camera at level 2
- const sky = std.mix(c.skyColor1, c.skyColor2, y / c.MAX_DIST);
-
- // fog coefficient
- const fog = std.min(march.ray.dist / c.MAX_DIST, 1);
-
- return std.mix(
- std.mix(d.vec4f(march.ray.color, 1), sky, fog),
- d.vec4f(march.glow, 1),
- glowIntensityUniform.$,
- );
-});
-
-// == PIPELINE ==
-const perlinCache = perlin3d.staticCache({
- root: root,
- size: d.vec3u(7),
-});
-
-let renderPipeline = root
- .with(floorPatternSlot, circles)
- .pipe(perlinCache.inject())
- .createRenderPipeline({
- vertex: vertexMain,
- fragment: fragmentMain,
- });
-
-let animationFrame: number;
-let floorAngle = 0;
-let sphereAngle = 0;
-let prevAngle = 0;
-function run(timestamp: number) {
- const curAngle = (((timestamp / 1000) % c.PERIOD) / c.PERIOD) * 2 * Math.PI;
- const delta = (curAngle + 2 * Math.PI - prevAngle) % (2 * Math.PI);
- prevAngle = curAngle;
-
- floorAngle += delta * floorSpeed;
- floorAngle %= c.NUM_CYCLES * Math.PI * 2;
- sphereAngle += delta * sphereSpeed;
- sphereAngle %= c.NUM_CYCLES * Math.PI * 2;
-
- floorAngleUniform.write(floorAngle);
- sphereAngleUniform.write(sphereAngle);
- resolutionUniform.write(d.vec2f(canvas.width, canvas.height));
-
- renderPipeline.withColorAttachment({ view: context }).draw(3);
-
- animationFrame = requestAnimationFrame(run);
-}
-
-animationFrame = requestAnimationFrame(run);
+const scene = await setupScene(root, context);
// #region Example controls and cleanup
export function onCleanup() {
- cancelAnimationFrame(animationFrame);
+ scene.onCleanup();
root.destroy();
}
@@ -171,44 +23,38 @@ export const controls = defineControls({
max: 1,
step: 0.01,
onSliderChange(value) {
- glowIntensityUniform.write(value);
+ scene.glowIntensity = value;
},
},
'floor speed': {
- initial: floorSpeed,
+ initial: 0.25,
min: -10,
max: 10,
step: 0.1,
onSliderChange(value) {
- floorSpeed = value;
+ scene.floorSpeed = value;
},
},
'sphere speed': {
- initial: sphereSpeed,
+ initial: 1,
min: -10,
max: 10,
step: 0.1,
onSliderChange(value) {
- sphereSpeed = value;
+ scene.sphereSpeed = value;
},
},
'sphere color': {
initial: c.initialSphereColor,
onColorChange: (value) => {
- sphereColorUniform.write(value);
+ scene.sphereColor = value;
},
},
'floor pattern': {
initial: 'circles',
options: ['grid', 'circles'],
onSelectChange: (value) => {
- renderPipeline = root
- .with(floorPatternSlot, value === 'grid' ? grid : circles)
- .pipe(perlinCache.inject())
- .createRenderPipeline({
- vertex: vertexMain,
- fragment: fragmentMain,
- });
+ scene.floorPattern = value;
},
},
});
diff --git a/apps/typegpu-docs/src/examples/simple/vaporrave/scene.ts b/apps/typegpu-docs/src/examples/simple/vaporrave/scene.ts
new file mode 100644
index 0000000000..8a6b54a4be
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/vaporrave/scene.ts
@@ -0,0 +1,184 @@
+import { perlin3d } from '@typegpu/noise';
+import { sdPlane } from '@typegpu/sdf';
+import tgpu, { d, std, type TgpuRoot } from 'typegpu';
+
+import * as c from './constants.ts';
+import { circles, grid } from './floor.ts';
+import { rayUnion } from './helpers.ts';
+import { getSphere } from './sphere.ts';
+import { LightRay, Ray } from './types.ts';
+
+// == INIT ==
+export async function setupScene(root: TgpuRoot, context: GPUCanvasContext) {
+ const canvas = context.canvas as HTMLCanvasElement;
+
+ // == BUFFERS ==
+ const floorAngleUniform = root.createUniform(d.f32);
+ const sphereAngleUniform = root.createUniform(d.f32);
+ const glowIntensityUniform = root.createUniform(d.f32, c.INITIAL_GLOW_INTENSITY);
+ const resolutionUniform = root.createUniform(d.vec2f);
+ const sphereColorUniform = root.createUniform(d.vec3f, c.initialSphereColor);
+
+ let floorSpeed = 0.25;
+ let sphereSpeed = 1;
+
+ const floorPatternSlot = tgpu.slot(circles);
+
+ // == RAYMARCHING ==
+
+ // returns smallest distance to some object in the scene
+ const getSceneRay = tgpu.fn(
+ [d.vec3f],
+ Ray,
+ )((p) => {
+ const floor = Ray({
+ dist: sdPlane(p, c.planeOrthonormal, c.PLANE_OFFSET),
+ color: floorPatternSlot.$(p.xz, floorAngleUniform.$),
+ });
+ const sphere = getSphere(p, sphereColorUniform.$, c.sphereCenter, sphereAngleUniform.$);
+
+ return rayUnion(floor, sphere);
+ });
+
+ const rayMarch = (ro: d.v3f, rd: d.v3f) => {
+ 'use gpu';
+ let distOrigin = d.f32();
+ const result = Ray({
+ dist: d.f32(c.MAX_DIST),
+ color: d.vec3f(),
+ });
+
+ let glow = d.vec3f();
+
+ for (let i = 0; i < c.MAX_STEPS; i++) {
+ const p = rd * distOrigin + ro;
+ const scene = getSceneRay(p);
+ const sphereDist = getSphere(p, sphereColorUniform.$, c.sphereCenter, sphereAngleUniform.$);
+
+ glow += d.vec3f(sphereColorUniform.$) * std.exp(-sphereDist.dist);
+
+ distOrigin += scene.dist;
+
+ if (distOrigin > c.MAX_DIST) {
+ result.dist = c.MAX_DIST;
+ break;
+ }
+
+ if (scene.dist < c.SURF_DIST) {
+ result.dist = distOrigin;
+ result.color = d.vec3f(scene.color);
+ break;
+ }
+ }
+
+ return LightRay({ ray: result, glow });
+ };
+
+ const vertexMain = tgpu.vertexFn({
+ in: { idx: d.builtin.vertexIndex },
+ out: { pos: d.builtin.position, uv: d.vec2f },
+ })(({ idx }) => {
+ const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
+ const uv = [d.vec2f(0, 0), d.vec2f(2, 0), d.vec2f(0, 2)];
+
+ return {
+ pos: d.vec4f(pos[idx], 0.0, 1.0),
+ uv: uv[idx],
+ };
+ });
+
+ const fragmentMain = tgpu.fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+ })((input) => {
+ 'use gpu';
+ const uv = input.uv * 2 - 1;
+ uv.x *= resolutionUniform.$.x / resolutionUniform.$.y;
+
+ // ray origin and direction
+ const ro = d.vec3f(0, 2, -1);
+ const rd = std.normalize(d.vec3f(uv.x, uv.y, 1));
+
+ // marching
+ const march = rayMarch(ro, rd);
+
+ // sky gradient
+ const y = rd.y * march.ray.dist + ro.y - 2; // camera at level 2
+ const sky = std.mix(c.skyColor1, c.skyColor2, y / c.MAX_DIST);
+
+ // fog coefficient
+ const fog = std.min(march.ray.dist / c.MAX_DIST, 1);
+
+ return std.mix(
+ std.mix(d.vec4f(march.ray.color, 1), sky, fog),
+ d.vec4f(march.glow, 1),
+ glowIntensityUniform.$,
+ );
+ });
+
+ // == PIPELINE ==
+ const perlinCache = perlin3d.staticCache({
+ root: root,
+ size: d.vec3u(7),
+ });
+
+ let renderPipeline = root
+ .with(floorPatternSlot, circles)
+ .pipe(perlinCache.inject())
+ .createRenderPipeline({
+ vertex: vertexMain,
+ fragment: fragmentMain,
+ });
+
+ let animationFrame: number;
+ let floorAngle = 0;
+ let sphereAngle = 0;
+ let prevAngle = 0;
+ function run(timestamp: number) {
+ const curAngle = (((timestamp / 1000) % c.PERIOD) / c.PERIOD) * 2 * Math.PI;
+ const delta = (curAngle + 2 * Math.PI - prevAngle) % (2 * Math.PI);
+ prevAngle = curAngle;
+
+ floorAngle += delta * floorSpeed;
+ floorAngle %= c.NUM_CYCLES * Math.PI * 2;
+ sphereAngle += delta * sphereSpeed;
+ sphereAngle %= c.NUM_CYCLES * Math.PI * 2;
+
+ floorAngleUniform.write(floorAngle);
+ sphereAngleUniform.write(sphereAngle);
+ resolutionUniform.write(d.vec2f(canvas.width, canvas.height));
+
+ renderPipeline.withColorAttachment({ view: context }).draw(3);
+
+ animationFrame = requestAnimationFrame(run);
+ }
+
+ animationFrame = requestAnimationFrame(run);
+
+ return {
+ set glowIntensity(value: number) {
+ glowIntensityUniform.write(value);
+ },
+ set floorSpeed(value: number) {
+ floorSpeed = value;
+ },
+ set sphereSpeed(value: number) {
+ sphereSpeed = value;
+ },
+ set sphereColor(value: d.v3f) {
+ sphereColorUniform.write(value);
+ },
+ set floorPattern(value: 'grid' | 'circles') {
+ renderPipeline = root
+ .with(floorPatternSlot, value === 'grid' ? grid : circles)
+ .pipe(perlinCache.inject())
+ .createRenderPipeline({
+ vertex: vertexMain,
+ fragment: fragmentMain,
+ });
+ },
+ onCleanup() {
+ cancelAnimationFrame(animationFrame);
+ },
+ };
+}
diff --git a/apps/typegpu-docs/src/fonts/font-face.css b/apps/typegpu-docs/src/fonts/font-face.css
index 7ef660e509..34b7bb7744 100644
--- a/apps/typegpu-docs/src/fonts/font-face.css
+++ b/apps/typegpu-docs/src/fonts/font-face.css
@@ -98,7 +98,7 @@
}
/* NOTE: Uncomment if needed */
-/* @font-face {
+@font-face {
font-family: 'Aeonik';
src: url('./Aeonik-Bold.otf') format('opentype');
font-weight: 700;
@@ -106,6 +106,7 @@
font-display: swap;
}
+/*
@font-face {
font-family: 'Aeonik';
src: url('./Aeonik-BoldItalic.otf') format('opentype');
diff --git a/apps/typegpu-docs/src/layouts/PageLayout.astro b/apps/typegpu-docs/src/layouts/PageLayout.astro
index 13276f92ff..aad6d95cec 100644
--- a/apps/typegpu-docs/src/layouts/PageLayout.astro
+++ b/apps/typegpu-docs/src/layouts/PageLayout.astro
@@ -8,7 +8,6 @@ import {
topBarBannerReservationScript,
} from '../components/TopBarBanner/shared';
import { TOP_BAR_BANNER } from '../components/TopBarBanner/config';
-const { title, theme = 'light' } = Astro.props;
const DEV = (import.meta as any).env.DEV;
const enableAnalytics = !DEV;
@@ -25,13 +24,33 @@ const reservationScript = firstZone
TOP_BAR_BANNER.hiddenPaths,
)
: '';
+
+interface Props {
+ title: string;
+ forceTheme?: 'light' | 'dark' | undefined;
+ noindex?: boolean;
+ edgeAwareOverscroll?: boolean;
+}
+
+const {
+ title,
+ forceTheme,
+ noindex = false,
+ edgeAwareOverscroll = false,
+} = Astro.props;
---
-
+
{title ?? 'TypeGPU'}
+ {noindex && }
{reservationScript && }
@@ -82,8 +101,24 @@ const reservationScript = firstZone
margin: 0;
}
+ [data-edge-aware-overscroll][data-theme='light'] {
+ background-color: white;
+ }
+
+ [data-edge-aware-overscroll][data-theme='dark'] {
+ background-color: #232736;
+ }
+
+ [data-edge-aware-overscroll][data-overscroll-edge='bottom'][data-theme='light'] {
+ background-color: #d0ddfb;
+ }
+
+ [data-edge-aware-overscroll][data-overscroll-edge='bottom'][data-theme='dark'] {
+ background-color: #33486f;
+ }
+
[data-theme='dark'] body {
- background-color: #171724;
+ background-color: #232736;
color: white;
}
@@ -105,7 +140,7 @@ const reservationScript = firstZone
enableAnalytics && (
-
+
{
enableAnalytics && (
diff --git a/apps/typegpu-docs/src/pages/benchmark/index.astro b/apps/typegpu-docs/src/pages/benchmark/index.astro
index e416429e21..c7c8e05243 100644
--- a/apps/typegpu-docs/src/pages/benchmark/index.astro
+++ b/apps/typegpu-docs/src/pages/benchmark/index.astro
@@ -5,7 +5,7 @@ import BenchmarkApp from './benchmark-app.tsx';
import TypeGPULogoDark from '../../assets/typegpu-logo-dark.svg';
---
-
+
— benchmark
diff --git a/apps/typegpu-docs/src/pages/examples/index.astro b/apps/typegpu-docs/src/pages/examples/index.astro
index cb9fcb239f..3694a42a66 100644
--- a/apps/typegpu-docs/src/pages/examples/index.astro
+++ b/apps/typegpu-docs/src/pages/examples/index.astro
@@ -4,7 +4,7 @@ import ExamplePage from '../../components/ExamplePage';
import PageLayout from '../../layouts/PageLayout.astro';
---
-
+
diff --git a/apps/typegpu-docs/src/pages/index.astro b/apps/typegpu-docs/src/pages/index.astro
index edb87a60b9..5fdaddf110 100644
--- a/apps/typegpu-docs/src/pages/index.astro
+++ b/apps/typegpu-docs/src/pages/index.astro
@@ -10,7 +10,7 @@ import TypeGPULogoLight from '../assets/typegpu-logo-light.svg';
import TypeGPULogomarkMonolight from '../assets/typegpu-logomark-monolight.svg';
import FeatureCard from '../components/FeatureCard.astro';
import FooterLink from '../components/FooterLink.astro';
-import GetStartedButton from '../components/GetStartedButton.astro';
+import GetStartedButton from '../components/legacy/GetStartedButton.astro';
import MilestoneBindGroupsBadge from '../components/roadmap/MilestoneBindGroupsBadge.astro';
import MilestoneBuffersBadge from '../components/roadmap/MilestoneBuffersBadge.astro';
import MilestoneCodeBadge from '../components/roadmap/MilestoneCodeBadge.astro';
@@ -23,7 +23,7 @@ import PageLayout from '../layouts/PageLayout.astro';
const showcaseVideoResolution = [2048, 1200];
---
-
+
diff --git a/apps/typegpu-docs/src/pages/new/index.astro b/apps/typegpu-docs/src/pages/new/index.astro
new file mode 100644
index 0000000000..cd88d363e2
--- /dev/null
+++ b/apps/typegpu-docs/src/pages/new/index.astro
@@ -0,0 +1,294 @@
+---
+import { Image } from 'astro:assets';
+import GithubIconSvg from '../../assets/github-icon.svg';
+import SwmLogomarkTopRightSvg from '../../assets/swm-logomark-top-right.svg';
+import TypeGPULogoLight from '../../assets/typegpu-logo-light.svg';
+import TypeGPULogoDark from '../../assets/typegpu-logo-dark.svg';
+import TypeGPULogomark from '../../assets/typegpu-logomark-light.svg';
+import CodeShowcase from '../../components/CodeShowcase.astro';
+import CallToActionButton from '../../components/CallToActionButton.astro';
+import GradientTitle from '../../components/GradientTitle.astro';
+import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
+import Videos from '../../components/Videos.astro';
+import PageLayout from '../../layouts/PageLayout.astro';
+import LandingExampleGrid from '../../components/landing-examples/LandingExampleGrid.tsx';
+import { HeroEffect } from '../../components/hero-effect/HeroEffect.tsx';
+import SecondaryButton from '../../components/SecondaryButton.astro';
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type
+
+
+ A modular and open-ended toolkit for WebGPU, with advanced type
+ interface and ability to write shaders in TypeScript.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Explore TypeGPU examples
+
+
+ From interactive 3D apps to custom shaders and type-safe libraries,
+ TypeGPU gives you the tools to build anything WebGPU can do.
+
+
+
+
+
+
+
+
+
+ Ways to use TypeGPU
+
+
+ TypeGPU is a three-way toolkit that is easy for you to integrate
+ granularly and doesn't restrict you in what you can do.
+
+
+
+
+
+ Read more about TypeGPU
+
+
+
+
+
+
+
+
+
+ Get to know TypeGPU
+
+
+ Dive into guides, tutorials, and videos to see how TypeGPU can change
+ the way you work with GPU rendering and computing.
+
+
+
+
+
+
+
+
+ Ready to try out ?
+
+
+
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/pages/translator/index.astro b/apps/typegpu-docs/src/pages/translator/index.astro
index d390ff7451..3f2bb06c82 100644
--- a/apps/typegpu-docs/src/pages/translator/index.astro
+++ b/apps/typegpu-docs/src/pages/translator/index.astro
@@ -5,7 +5,7 @@ import TranslatorApp from '../../components/translator/TranslatorApp';
import TypeGPULogoDark from '../../assets/typegpu-logo-dark.svg';
---
-
+