Skip to content

Commit ef26faf

Browse files
authored
feat: @typegpu/sdf package (#1428)
* Initialized the @typegpu/sdf package * Simple ray-marched scene * Soft shadows * Smooth-min, more precise shadows * Colors * Refactor * Box frame instead of a box * Prettier floor, more shadow length * Thumbnail * Update ray marching example * Stable fn * Fix vertex fn * More tags * Refactor * Better names & JSDoc * Cleanup * Fix broken lockfile * Review fixes * Update lock file * Smaller far distance, fog * Update checker-board pattern
1 parent 732a7a5 commit ef26faf

File tree

14 files changed

+501
-0
lines changed

14 files changed

+501
-0
lines changed

apps/typegpu-docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@tailwindcss/vite": "^4.1.6",
2525
"@typegpu/color": "workspace:*",
2626
"@typegpu/noise": "workspace:*",
27+
"@typegpu/sdf": "workspace:*",
2728
"@types/dom-mediacapture-transform": "^0.1.9",
2829
"@types/react": "^19.0.10",
2930
"@types/react-dom": "^19.0.4",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<canvas data-fit-to-container></canvas>
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import tgpu from 'typegpu';
2+
import * as d from 'typegpu/data';
3+
import * as std from 'typegpu/std';
4+
import { sdBoxFrame3d, sdPlane, sdSphere } from '@typegpu/sdf';
5+
6+
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
7+
const context = canvas.getContext('webgpu') as GPUCanvasContext;
8+
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
9+
10+
const root = await tgpu.init();
11+
12+
context.configure({
13+
device: root.device,
14+
format: presentationFormat,
15+
alphaMode: 'premultiplied',
16+
});
17+
18+
const time = root.createUniform(d.f32);
19+
const resolution = root.createUniform(d.vec2f);
20+
21+
const MAX_STEPS = 1000;
22+
const MAX_DIST = 30;
23+
const SURF_DIST = 0.001;
24+
25+
const skyColor = d.vec4f(0.7, 0.8, 0.9, 1);
26+
27+
// Structure to hold both distance and color
28+
const Shape = d.struct({
29+
color: d.vec3f,
30+
dist: d.f32,
31+
});
32+
33+
const checkerBoard = tgpu.fn([d.vec2f], d.f32)((uv) => {
34+
const fuv = std.floor(uv);
35+
return std.abs(fuv.x + fuv.y) % 2;
36+
});
37+
38+
const smoothShapeUnion = tgpu.fn([Shape, Shape, d.f32], Shape)((a, b, k) => {
39+
const h = std.max(k - std.abs(a.dist - b.dist), 0) / k;
40+
const m = h * h;
41+
42+
// Smooth min for distance
43+
const dist = std.min(a.dist, b.dist) - m * k * (1 / d.f32(4));
44+
45+
// Blend colors based on relative distances and smoothing
46+
const weight = m + std.select(0, 1 - m, a.dist > b.dist);
47+
const color = std.mix(a.color, b.color, weight);
48+
49+
return { dist, color };
50+
});
51+
52+
const shapeUnion = tgpu.fn([Shape, Shape], Shape)((a, b) => ({
53+
color: std.select(a.color, b.color, a.dist > b.dist),
54+
dist: std.min(a.dist, b.dist),
55+
}));
56+
57+
const getMorphingShape = tgpu.fn([d.vec3f, d.f32], Shape)((p, t) => {
58+
// Center position
59+
const center = d.vec3f(0, 2, 6);
60+
const localP = std.sub(p, center);
61+
const rotMatZ = d.mat4x4f.rotationZ(-t);
62+
const rotMatX = d.mat4x4f.rotationX(-t * 0.6);
63+
const rotatedP = std.mul(rotMatZ, std.mul(rotMatX, d.vec4f(localP, 1))).xyz;
64+
65+
// Animate shapes
66+
const boxSize = d.vec3f(0.7);
67+
68+
// Create two spheres that move in a circular pattern
69+
const sphere1Offset = d.vec3f(
70+
std.cos(t * 2) * 0.8,
71+
std.sin(t * 3) * 0.3,
72+
std.sin(t * 2) * 0.8,
73+
);
74+
const sphere2Offset = d.vec3f(
75+
std.cos(t * 2 + 3.14) * 0.8,
76+
std.sin(t * 3 + 1.57) * 0.3,
77+
std.sin(t * 2 + 3.14) * 0.8,
78+
);
79+
80+
// Calculate distances and assign colors
81+
const sphere1 = Shape({
82+
dist: sdSphere(std.sub(localP, sphere1Offset), 0.5),
83+
color: d.vec3f(0.4, 0.5, 1),
84+
});
85+
const sphere2 = Shape({
86+
dist: sdSphere(std.sub(localP, sphere2Offset), 0.3),
87+
color: d.vec3f(1, 0.8, 0.2),
88+
});
89+
const box = Shape({
90+
dist: sdBoxFrame3d(rotatedP, boxSize, 0.1),
91+
color: d.vec3f(1.0, 0.3, 0.3),
92+
});
93+
94+
// Smoothly blend shapes and colors
95+
const spheres = smoothShapeUnion(sphere1, sphere2, 0.1);
96+
return smoothShapeUnion(spheres, box, 0.2);
97+
});
98+
99+
const getSceneDist = tgpu.fn([d.vec3f], Shape)((p) => {
100+
const shape = getMorphingShape(p, time.$);
101+
const floor = Shape({
102+
dist: sdPlane(p, d.vec3f(0, 1, 0), 0),
103+
color: std.mix(
104+
d.vec3f(1),
105+
d.vec3f(0.2),
106+
checkerBoard(std.mul(p.xz, 2)),
107+
),
108+
});
109+
110+
return shapeUnion(shape, floor);
111+
});
112+
113+
const rayMarch = tgpu.fn([d.vec3f, d.vec3f], Shape)((ro, rd) => {
114+
let dO = d.f32(0);
115+
const result = Shape({
116+
dist: d.f32(MAX_DIST),
117+
color: d.vec3f(0, 0, 0),
118+
});
119+
120+
for (let i = 0; i < MAX_STEPS; i++) {
121+
const p = std.add(ro, std.mul(rd, dO));
122+
const scene = getSceneDist(p);
123+
dO += scene.dist;
124+
125+
if (dO > MAX_DIST || scene.dist < SURF_DIST) {
126+
result.dist = dO;
127+
result.color = scene.color;
128+
break;
129+
}
130+
}
131+
132+
return result;
133+
});
134+
135+
const softShadow = tgpu.fn(
136+
[d.vec3f, d.vec3f, d.f32, d.f32, d.f32],
137+
d.f32,
138+
)((ro, rd, minT, maxT, k) => {
139+
let res = d.f32(1);
140+
let t = minT;
141+
142+
for (let i = 0; i < 100; i++) {
143+
if (t >= maxT) break;
144+
const h = getSceneDist(std.add(ro, std.mul(rd, t))).dist;
145+
if (h < 0.001) return 0;
146+
res = std.min(res, k * h / t);
147+
t += std.max(h, 0.001);
148+
}
149+
150+
return res;
151+
});
152+
153+
const getNormal = tgpu.fn([d.vec3f], d.vec3f)((p) => {
154+
const dist = getSceneDist(p).dist;
155+
const e = 0.01;
156+
157+
const n = d.vec3f(
158+
getSceneDist(std.add(p, d.vec3f(e, 0, 0))).dist - dist,
159+
getSceneDist(std.add(p, d.vec3f(0, e, 0))).dist - dist,
160+
getSceneDist(std.add(p, d.vec3f(0, 0, e))).dist - dist,
161+
);
162+
163+
return std.normalize(n);
164+
});
165+
166+
const getOrbitingLightPos = tgpu.fn([d.f32], d.vec3f)((t) => {
167+
const radius = d.f32(3);
168+
const height = d.f32(6);
169+
const speed = d.f32(1);
170+
171+
return d.vec3f(
172+
std.cos(t * speed) * radius,
173+
height + std.sin(t * speed) * radius,
174+
4,
175+
);
176+
});
177+
178+
const vertexMain = tgpu['~unstable'].vertexFn({
179+
in: { idx: d.builtin.vertexIndex },
180+
out: { pos: d.builtin.position, uv: d.vec2f },
181+
})(({ idx }) => {
182+
const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
183+
const uv = [d.vec2f(0, 0), d.vec2f(2, 0), d.vec2f(0, 2)];
184+
185+
return {
186+
pos: d.vec4f(pos[idx], 0.0, 1.0),
187+
uv: uv[idx],
188+
};
189+
});
190+
191+
const fragmentMain = tgpu['~unstable'].fragmentFn({
192+
in: { uv: d.vec2f },
193+
out: d.vec4f,
194+
})((input) => {
195+
const uv = std.sub(std.mul(input.uv, 2), 1);
196+
uv.x *= resolution.$.x / resolution.$.y;
197+
198+
// Ray origin and direction
199+
const ro = d.vec3f(0, 2, 3);
200+
const rd = std.normalize(d.vec3f(uv.x, uv.y, 1));
201+
202+
const march = rayMarch(ro, rd);
203+
204+
const fog = std.pow(std.min(march.dist / MAX_DIST, 1), 0.7);
205+
206+
const p = std.add(ro, std.mul(rd, march.dist));
207+
const n = getNormal(p);
208+
209+
// Lighting with orbiting light
210+
const lightPos = getOrbitingLightPos(time.$);
211+
const l = std.normalize(std.sub(lightPos, p));
212+
const diff = std.max(std.dot(n, l), 0);
213+
214+
// Soft shadows
215+
const shadowRo = p;
216+
const shadowRd = l;
217+
const shadowDist = std.length(std.sub(lightPos, p));
218+
const shadow = softShadow(shadowRo, shadowRd, 0.1, shadowDist, 16);
219+
220+
// Combine lighting with shadows and color
221+
const litColor = std.mul(march.color, diff);
222+
const finalColor = std.mix(
223+
std.mul(litColor, 0.5), // Shadow color
224+
litColor, // Lit color
225+
shadow,
226+
);
227+
228+
return std.mix(d.vec4f(finalColor, 1), skyColor, fog);
229+
});
230+
231+
const renderPipeline = root['~unstable']
232+
.withVertex(vertexMain, {})
233+
.withFragment(fragmentMain, { format: presentationFormat })
234+
.createPipeline();
235+
236+
let animationFrame: number;
237+
function run(timestamp: number) {
238+
time.write(timestamp / 1000 % 1000);
239+
resolution.write(d.vec2f(canvas.width, canvas.height));
240+
241+
renderPipeline
242+
.withColorAttachment({
243+
view: context.getCurrentTexture().createView(),
244+
loadOp: 'clear',
245+
storeOp: 'store',
246+
})
247+
.draw(3);
248+
249+
animationFrame = requestAnimationFrame(run);
250+
}
251+
252+
animationFrame = requestAnimationFrame(run);
253+
254+
export function onCleanup() {
255+
cancelAnimationFrame(animationFrame);
256+
root.destroy();
257+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"title": "Ray Marching",
3+
"category": "rendering",
4+
"tags": [
5+
"experimental",
6+
"fragment shader",
7+
"shadows",
8+
"sdf",
9+
"ray marching",
10+
"sphere tracing"
11+
]
12+
}
502 KB
Loading

packages/typegpu-sdf/build.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineBuildConfig } from 'unbuild';
2+
import typegpu from 'unplugin-typegpu/rollup';
3+
4+
export default defineBuildConfig({
5+
hooks: {
6+
'rollup:options': (_options, config) => {
7+
config.plugins.push(typegpu({ include: [/\.ts$/] }));
8+
},
9+
},
10+
});

packages/typegpu-sdf/deno.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"exclude": ["."],
3+
"fmt": {
4+
"exclude": ["!.", "./dist"],
5+
"singleQuote": true
6+
}
7+
}

packages/typegpu-sdf/package.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@typegpu/sdf",
3+
"type": "module",
4+
"version": "0.0.1",
5+
"description": "A set of Signed Distance Field functions for use in WebGPU/TypeGPU apps.",
6+
"exports": {
7+
".": "./src/index.ts",
8+
"./package.json": "./package.json"
9+
},
10+
"publishConfig": {
11+
"directory": "dist",
12+
"linkDirectory": false,
13+
"main": "./dist/index.mjs",
14+
"types": "./dist/index.d.ts",
15+
"exports": {
16+
"./package.json": "./dist/package.json",
17+
".": {
18+
"types": "./dist/index.d.ts",
19+
"module": "./dist/index.mjs",
20+
"import": "./dist/index.mjs",
21+
"default": "./dist/index.cjs"
22+
}
23+
}
24+
},
25+
"sideEffects": false,
26+
"scripts": {
27+
"build": "unbuild",
28+
"test:types": "pnpm tsc --p ./tsconfig.json --noEmit",
29+
"prepublishOnly": "tgpu-dev-cli prepack"
30+
},
31+
"keywords": [],
32+
"license": "MIT",
33+
"peerDependencies": {
34+
"typegpu": "^0.5.9"
35+
},
36+
"devDependencies": {
37+
"@typegpu/tgpu-dev-cli": "workspace:*",
38+
"@webgpu/types": "catalog:types",
39+
"typegpu": "workspace:*",
40+
"typescript": "catalog:types",
41+
"unbuild": "catalog:build",
42+
"unplugin-typegpu": "workspace:*"
43+
}
44+
}

packages/typegpu-sdf/src/2d.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import tgpu from 'typegpu';
2+
import { f32, vec2f } from 'typegpu/data';
3+
import { abs, add, length, max, min, sub } from 'typegpu/std';
4+
5+
/**
6+
* Signed distance function for a disk (filled circle)
7+
* @param p Point to evaluate
8+
* @param radius Radius of the disk
9+
*/
10+
export const sdDisk = tgpu.fn([vec2f, f32], f32)((p, radius) => {
11+
return length(p) - radius;
12+
});
13+
14+
/**
15+
* Signed distance function for a 2d box
16+
* @param p Point to evaluate
17+
* @param size Half-dimensions of the box
18+
*/
19+
export const sdBox2d = tgpu.fn([vec2f, vec2f], f32)((p, size) => {
20+
const d = sub(abs(p), size);
21+
return length(max(d, vec2f(0))) + min(max(d.x, d.y), 0);
22+
});
23+
24+
/**
25+
* Signed distance function for a rounded 2d box
26+
* @param p Point to evaluate
27+
* @param size Half-dimensions of the box
28+
* @param cornerRadius Box corner radius
29+
*/
30+
export const sdRoundedBox2d = tgpu
31+
.fn([vec2f, vec2f, f32], f32)((p, size, cornerRadius) => {
32+
const d = add(sub(abs(p), size), vec2f(cornerRadius));
33+
return length(max(d, vec2f(0))) + min(max(d.x, d.y), 0) - cornerRadius;
34+
});

0 commit comments

Comments
 (0)