Skip to content

feat: @typegpu/sdf package #1428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Jul 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e301ba4
Initialized the @typegpu/sdf package
iwoplaza Jun 29, 2025
b236d64
Simple ray-marched scene
iwoplaza Jun 29, 2025
7154b3f
Soft shadows
iwoplaza Jun 30, 2025
69ef7a5
Smooth-min, more precise shadows
iwoplaza Jun 30, 2025
6ef7a63
Colors
iwoplaza Jun 30, 2025
c2d65c6
Refactor
iwoplaza Jun 30, 2025
2dca6a7
Box frame instead of a box
iwoplaza Jun 30, 2025
9e3edd3
Prettier floor, more shadow length
iwoplaza Jun 30, 2025
5665c49
Thumbnail
iwoplaza Jun 30, 2025
198c999
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jun 30, 2025
1ab40e5
Update ray marching example
iwoplaza Jun 30, 2025
c6579c7
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jun 30, 2025
6613e9b
Stable fn
iwoplaza Jun 30, 2025
9c5f149
Fix vertex fn
iwoplaza Jun 30, 2025
e87f97e
More tags
iwoplaza Jun 30, 2025
480f9d9
Refactor
iwoplaza Jun 30, 2025
be64783
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jul 1, 2025
7651249
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jul 3, 2025
a7d5b41
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jul 3, 2025
c11310a
Better names & JSDoc
iwoplaza Jul 3, 2025
4a07f54
Cleanup
iwoplaza Jul 3, 2025
8b5b064
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jul 3, 2025
9a83378
Merge branch 'main' into feat/typegpu-sdf
iwoplaza Jul 4, 2025
a1e32a9
Fix broken lockfile
iwoplaza Jul 4, 2025
0e916bf
Review fixes
iwoplaza Jul 4, 2025
2a3300c
Update lock file
iwoplaza Jul 4, 2025
50d96c3
Smaller far distance, fog
iwoplaza Jul 4, 2025
17a01b6
Update checker-board pattern
iwoplaza Jul 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/typegpu-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@tailwindcss/vite": "^4.1.6",
"@typegpu/color": "workspace:*",
"@typegpu/noise": "workspace:*",
"@typegpu/sdf": "workspace:*",
"@types/dom-mediacapture-transform": "^0.1.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<canvas data-fit-to-container></canvas>
257 changes: 257 additions & 0 deletions apps/typegpu-docs/src/content/examples/rendering/ray-marching/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import tgpu from 'typegpu';
import * as d from 'typegpu/data';
import * as std from 'typegpu/std';
import { sdBoxFrame3d, sdPlane, sdSphere } from '@typegpu/sdf';

const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = canvas.getContext('webgpu') as GPUCanvasContext;
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();

const root = await tgpu.init();

context.configure({
device: root.device,
format: presentationFormat,
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
const Shape = d.struct({
color: d.vec3f,
dist: d.f32,
});

const checkerBoard = tgpu.fn([d.vec2f], d.f32)((uv) => {
const fuv = std.floor(uv);
return std.abs(fuv.x + fuv.y) % 2;
});

const smoothShapeUnion = tgpu.fn([Shape, Shape, d.f32], Shape)((a, b, k) => {
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 { dist, color };
});

const shapeUnion = tgpu.fn([Shape, Shape], Shape)((a, b) => ({
color: std.select(a.color, b.color, a.dist > b.dist),
dist: std.min(a.dist, b.dist),
}));

const getMorphingShape = tgpu.fn([d.vec3f, d.f32], Shape)((p, t) => {
// 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 = tgpu.fn([d.vec3f], Shape)((p) => {
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 = tgpu.fn([d.vec3f, d.vec3f], Shape)((ro, rd) => {
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 = std.add(ro, std.mul(rd, dO));
const scene = getSceneDist(p);
dO += scene.dist;

if (dO > MAX_DIST || scene.dist < SURF_DIST) {
result.dist = dO;
result.color = scene.color;
break;
}
}

return result;
});

const softShadow = tgpu.fn(
[d.vec3f, d.vec3f, d.f32, d.f32, d.f32],
d.f32,
)((ro, rd, minT, maxT, k) => {
let res = d.f32(1);
let t = minT;

for (let i = 0; i < 100; i++) {
if (t >= maxT) break;
const h = getSceneDist(std.add(ro, std.mul(rd, 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 = tgpu.fn([d.vec3f], d.vec3f)((p) => {
const dist = getSceneDist(p).dist;
const e = 0.01;

const n = d.vec3f(
getSceneDist(std.add(p, d.vec3f(e, 0, 0))).dist - dist,
getSceneDist(std.add(p, d.vec3f(0, e, 0))).dist - dist,
getSceneDist(std.add(p, d.vec3f(0, 0, e))).dist - dist,
);

return std.normalize(n);
});

const getOrbitingLightPos = tgpu.fn([d.f32], d.vec3f)((t) => {
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['~unstable'].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['~unstable'].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(std.sub(lightPos, p));
const diff = std.max(std.dot(n, l), 0);

// Soft shadows
const shadowRo = p;
const shadowRd = l;
const shadowDist = std.length(std.sub(lightPos, p));
const shadow = softShadow(shadowRo, shadowRd, 0.1, shadowDist, 16);

// Combine lighting with shadows and color
const litColor = std.mul(march.color, 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['~unstable']
.withVertex(vertexMain, {})
.withFragment(fragmentMain, { format: presentationFormat })
.createPipeline();

let animationFrame: number;
function run(timestamp: number) {
time.write(timestamp / 1000 % 1000);
resolution.write(d.vec2f(canvas.width, canvas.height));

renderPipeline
.withColorAttachment({
view: context.getCurrentTexture().createView(),
loadOp: 'clear',
storeOp: 'store',
})
.draw(3);

animationFrame = requestAnimationFrame(run);
}

animationFrame = requestAnimationFrame(run);

export function onCleanup() {
cancelAnimationFrame(animationFrame);
root.destroy();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"title": "Ray Marching",
"category": "rendering",
"tags": [
"experimental",
"fragment shader",
"shadows",
"sdf",
"ray marching",
"sphere tracing"
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/typegpu-sdf/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineBuildConfig } from 'unbuild';
import typegpu from 'unplugin-typegpu/rollup';

export default defineBuildConfig({
hooks: {
'rollup:options': (_options, config) => {
config.plugins.push(typegpu({ include: [/\.ts$/] }));
},
},
});
7 changes: 7 additions & 0 deletions packages/typegpu-sdf/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"exclude": ["."],
"fmt": {
"exclude": ["!.", "./dist"],
"singleQuote": true
}
}
44 changes: 44 additions & 0 deletions packages/typegpu-sdf/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@typegpu/sdf",
"type": "module",
"version": "0.0.1",
"description": "A set of Signed Distance Field functions for use in WebGPU/TypeGPU apps.",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"publishConfig": {
"directory": "dist",
"linkDirectory": false,
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./dist/package.json",
".": {
"types": "./dist/index.d.ts",
"module": "./dist/index.mjs",
"import": "./dist/index.mjs",
"default": "./dist/index.cjs"
}
}
},
"sideEffects": false,
"scripts": {
"build": "unbuild",
"test:types": "pnpm tsc --p ./tsconfig.json --noEmit",
"prepublishOnly": "tgpu-dev-cli prepack"
},
"keywords": [],
"license": "MIT",
"peerDependencies": {
"typegpu": "^0.5.9"
},
"devDependencies": {
"@typegpu/tgpu-dev-cli": "workspace:*",
"@webgpu/types": "catalog:types",
"typegpu": "workspace:*",
"typescript": "catalog:types",
"unbuild": "catalog:build",
"unplugin-typegpu": "workspace:*"
}
}
34 changes: 34 additions & 0 deletions packages/typegpu-sdf/src/2d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import tgpu from 'typegpu';
import { f32, vec2f } from 'typegpu/data';
import { abs, add, length, max, min, sub } from 'typegpu/std';

/**
* Signed distance function for a disk (filled circle)
* @param p Point to evaluate
* @param radius Radius of the disk
*/
export const sdDisk = tgpu.fn([vec2f, f32], f32)((p, radius) => {
return length(p) - radius;
});

/**
* Signed distance function for a 2d box
* @param p Point to evaluate
* @param size Half-dimensions of the box
*/
export const sdBox2d = tgpu.fn([vec2f, vec2f], f32)((p, size) => {
const d = sub(abs(p), size);
return length(max(d, vec2f(0))) + min(max(d.x, d.y), 0);
});

/**
* Signed distance function for a rounded 2d box
* @param p Point to evaluate
* @param size Half-dimensions of the box
* @param cornerRadius Box corner radius
*/
export const sdRoundedBox2d = tgpu
.fn([vec2f, vec2f, f32], f32)((p, size, cornerRadius) => {
const d = add(sub(abs(p), size), vec2f(cornerRadius));
return length(max(d, vec2f(0))) + min(max(d.x, d.y), 0) - cornerRadius;
});
Loading