Skip to content

Commit 47010e0

Browse files
r1tsuucodex
andcommitted
refactor: move lighting fully into native C
Replace the remaining TypeScript lighting implementation with a native C backend and expose it through a shared/server FFI wrapper. Split the relight hot path into its own translation unit, add the benchmark runner layout, and update docs, plan 49, and CI to match the shipped native-lighting architecture. Co-authored-by: Codex <codex@openai.com>
1 parent 9366e66 commit 47010e0

18 files changed

Lines changed: 1331 additions & 400 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ jobs:
1515
name: Format, Lint, Native Build, Typecheck, and Tests
1616
runs-on: macos-latest
1717
timeout-minutes: 20
18-
env:
19-
HOMEBREW_NO_AUTO_UPDATE: '1'
2018

2119
steps:
2220
- name: Check out repository
@@ -25,9 +23,6 @@ jobs:
2523
- name: Set up Bun
2624
uses: oven-sh/setup-bun@v2
2725

28-
- name: Install GLFW
29-
run: brew install glfw
30-
3126
- name: Install dependencies
3227
run: bun install --frozen-lockfile
3328

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/node_modules
22
/client
33
/native/libvoxel_bridge.dylib
4+
/native/liblighting.dylib
45
/native/vendor
56
/apps/client/dist
67
/apps/dedicated-server/dist

README.md

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
<video src="https://github.com/user-attachments/assets/220c9092-4e4d-4c01-9847-8f0924e5b18c" autoplay loop muted playsinline></video>
44

5-
A macOS-first Bun desktop voxel sandbox. A thin C bridge handles GLFW windowing and OpenGL rendering; Bun workspaces split the desktop client, dedicated server, and shared gameplay/runtime code into explicit packages.
5+
A macOS-first Bun desktop voxel sandbox. A thin C bridge handles GLFW windowing and OpenGL rendering, while a separate C native module handles chunk lighting propagation; Bun workspaces split the desktop client, dedicated server, and shared gameplay/runtime code into explicit packages.
66

7-
The native desktop build downloads the official GLFW source package on demand and builds it locally, so contributors do not need a separate `brew install glfw` step.
7+
The native desktop build downloads the official GLFW source package on demand, builds it locally, and also compiles the lighting library from source, so contributors do not need a separate `brew install glfw` step.
88

99
## Quick Start
1010

@@ -48,10 +48,23 @@ Use `bun run dev:full` to start a dedicated WebSocket server alongside the clien
4848

4949
### Build
5050

51-
| Command | Description |
52-
| ---------------------- | ---------------------------------------------------------- |
53-
| `bun run build:native` | Build `native/libvoxel_bridge.dylib` |
54-
| `bun run clean:data` | Remove `apps/client/dist` and `apps/dedicated-server/dist` |
51+
| Command | Description |
52+
| ---------------------- | ------------------------------------------------------------------- |
53+
| `bun run build:native` | Build `native/libvoxel_bridge.dylib` and `native/liblighting.dylib` |
54+
| `bun run clean:data` | Remove `apps/client/dist` and `apps/dedicated-server/dist` |
55+
56+
### Benchmarks
57+
58+
| Command | Description |
59+
| ------------------------------- | ------------------------------------------------ |
60+
| `bun run benchmark` | Build native libraries, then run all benchmarks |
61+
| `bun run benchmark -- lighting` | Build native libraries, then run only `lighting` |
62+
63+
Pass benchmark-specific flags after the benchmark name:
64+
65+
```sh
66+
bun run benchmark -- lighting --warmup=5 --rounds=40
67+
```
5568

5669
### Asset Generation
5770

@@ -195,7 +208,7 @@ packages/
195208
world/ Chunks, terrain/biome generation, meshing, atlas UVs, content spec,
196209
generated registries, inventory helpers, raycasting
197210
math/ Shared math helpers
198-
native/ GLFW/OpenGL C bridge
211+
native/ GLFW/OpenGL C bridge plus C lighting module
199212
plans/ Implementation plans for major feature work
200213
tests/ Coverage for client/server flow, storage, terrain, meshing, and UI
201214
```
@@ -236,16 +249,18 @@ Content authoring and the asset pipeline are documented in [`ARCHITECTURE.md`](.
236249

237250
## Code Size Snapshot
238251

239-
Source-line snapshot as of 2026-03-26 using `wc -l` over TypeScript, C, and shader files. Excludes docs, JSON/package metadata, lockfiles, and binary assets.
252+
Source-line snapshot as of 2026-03-29 using `wc -l` over TypeScript, C/C
253+
header, and shader files. Excludes docs, JSON/package metadata, lockfiles,
254+
binary assets, and downloaded vendored native dependencies under `native/vendor`.
240255

241256
| Package | Lines |
242257
| ---------------------------- | ---------- |
243-
| `apps/client/src` | 8,782 TS |
244-
| `apps/client/assets/shaders` | 118 GLSL |
258+
| `apps/client/src` | 9,552 TS |
259+
| `apps/client/assets/shaders` | 122 GLSL |
245260
| `apps/dedicated-server` | 343 TS |
246-
| `packages/core` | 8,548 TS |
247-
| `apps/cli/src` | 1,160 TS |
248-
| `native` | 459 C |
249-
| `tests` | 5,029 TS |
250-
| **Total (with tests)** | **24,439** |
251-
| **Total (without tests)** | **19,410** |
261+
| `packages/core` | 9,426 TS |
262+
| `apps/cli/src` | 1,578 TS |
263+
| `native` | 969 C/H |
264+
| `tests` | 6,128 TS |
265+
| **Total (with tests)** | **28,118** |
266+
| **Total (without tests)** | **21,990** |

apps/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"private": true,
44
"type": "module",
55
"scripts": {
6+
"benchmark": "bun run src/benchmark.ts",
67
"build-native": "bun run src/build-native.ts",
78
"clean-data": "bun run src/clean-data.ts",
89
"dev-full": "bun run src/dev-full.ts",

apps/cli/src/benchmark.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CliBenchmark } from './benchmarks/types.ts'
2+
3+
import { lightingBenchmark } from './benchmarks/lighting.ts'
4+
5+
const benchmarks: readonly CliBenchmark[] = [lightingBenchmark]
6+
7+
const argv = Bun.argv.slice(2)
8+
const requestedName = argv[0] && !argv[0].startsWith('--') ? argv[0] : null
9+
const benchmarkArgs = requestedName ? argv.slice(1) : argv
10+
11+
const selectedBenchmarks = requestedName
12+
? benchmarks.filter((benchmark) => benchmark.name === requestedName)
13+
: benchmarks
14+
15+
if (selectedBenchmarks.length === 0) {
16+
throw new Error(
17+
`Unknown benchmark "${requestedName}". Available benchmarks: ${benchmarks.map((benchmark) => benchmark.name).join(', ')}`,
18+
)
19+
}
20+
21+
if (selectedBenchmarks.length > 1) {
22+
console.log(
23+
`Running ${selectedBenchmarks.length} benchmarks: ${selectedBenchmarks.map((benchmark) => benchmark.name).join(', ')}`,
24+
)
25+
} else {
26+
console.log(`Running benchmark: ${selectedBenchmarks[0]!.name}`)
27+
}
28+
29+
for (let index = 0; index < selectedBenchmarks.length; index += 1) {
30+
const benchmark = selectedBenchmarks[index]!
31+
if (index > 0) {
32+
console.log('')
33+
}
34+
35+
console.log(`[${benchmark.name}] ${benchmark.description}`)
36+
await benchmark.run(benchmarkArgs)
37+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { createLightingChunkInputFromChunk, LightingSystem } from '@craftvale/core/server'
2+
import { parseCliFlagValue } from '@craftvale/core/shared'
3+
import { BLOCK_IDS, CHUNK_SIZE, CHUNK_VOLUME, createGeneratedChunk } from '@craftvale/core/shared'
4+
5+
import type { CliBenchmark } from './types.ts'
6+
7+
const DEFAULT_SEED = 1337
8+
const DEFAULT_WARMUP_ROUNDS = 20
9+
const DEFAULT_MEASURED_ROUNDS = 140
10+
const DEFAULT_FIXTURE_COORDS = [
11+
{ x: -8, z: -6 },
12+
{ x: -7, z: -2 },
13+
{ x: -6, z: 1 },
14+
{ x: -5, z: 5 },
15+
{ x: -4, z: -8 },
16+
{ x: -3, z: -3 },
17+
{ x: -2, z: 2 },
18+
{ x: -1, z: 7 },
19+
{ x: 0, z: -7 },
20+
{ x: 1, z: -4 },
21+
{ x: 2, z: 0 },
22+
{ x: 3, z: 4 },
23+
{ x: 4, z: 8 },
24+
{ x: 5, z: -5 },
25+
{ x: 6, z: -1 },
26+
{ x: 7, z: 3 },
27+
] as const
28+
const FEATURE_POINTS = [
29+
[4, 4],
30+
[7, 9],
31+
[11, 6],
32+
] as const
33+
34+
interface LightingBuffers {
35+
sky: Uint8Array
36+
block: Uint8Array
37+
}
38+
39+
interface BenchmarkResult {
40+
label: string
41+
totalMs: number
42+
meanMs: number
43+
medianMs: number
44+
minMs: number
45+
maxMs: number
46+
}
47+
48+
const parsePositiveIntegerFlag = (
49+
argv: readonly string[],
50+
flagName: string,
51+
fallback: number,
52+
): number => {
53+
const value = parseCliFlagValue(argv, flagName)
54+
if (value === null) {
55+
return fallback
56+
}
57+
58+
const parsed = Number.parseInt(value, 10)
59+
if (!Number.isFinite(parsed) || parsed <= 0) {
60+
throw new Error(`Expected --${flagName} to be a positive integer, got "${value}".`)
61+
}
62+
63+
return parsed
64+
}
65+
66+
const clampY = (value: number): number => Math.max(0, Math.min(255, value))
67+
68+
const createFixtureChunk = (coord: { x: number; z: number }, seed: number) => {
69+
const chunk = createGeneratedChunk(coord, seed)
70+
71+
for (const [localX, localZ] of FEATURE_POINTS) {
72+
const columnIndex = localX + CHUNK_SIZE * localZ
73+
const surfaceY = chunk.heightmap[columnIndex] ?? 0
74+
const glowstoneY = clampY(surfaceY + 1)
75+
const roofY = clampY(surfaceY + 3)
76+
const cavityY = clampY(surfaceY + 2)
77+
78+
chunk.set(localX, glowstoneY, localZ, BLOCK_IDS.glowstone)
79+
if (localX + 1 < CHUNK_SIZE) {
80+
chunk.set(localX + 1, roofY, localZ, BLOCK_IDS.grass)
81+
chunk.set(localX + 1, cavityY, localZ, BLOCK_IDS.air)
82+
}
83+
if (localZ + 1 < CHUNK_SIZE) {
84+
chunk.set(localX, clampY(surfaceY + 1), localZ + 1, BLOCK_IDS.glass)
85+
}
86+
}
87+
88+
chunk.dirtyLight = true
89+
return chunk
90+
}
91+
92+
const createBuffers = (count: number): LightingBuffers[] =>
93+
Array.from({ length: count }, () => ({
94+
sky: new Uint8Array(CHUNK_VOLUME),
95+
block: new Uint8Array(CHUNK_VOLUME),
96+
}))
97+
98+
const measure = (
99+
label: string,
100+
runRound: () => void,
101+
warmupRounds: number,
102+
measuredRounds: number,
103+
): BenchmarkResult => {
104+
for (let round = 0; round < warmupRounds; round += 1) {
105+
runRound()
106+
}
107+
108+
const samples: number[] = []
109+
for (let round = 0; round < measuredRounds; round += 1) {
110+
const startedAt = performance.now()
111+
runRound()
112+
samples.push(performance.now() - startedAt)
113+
}
114+
115+
const totalMs = samples.reduce((sum, value) => sum + value, 0)
116+
const meanMs = totalMs / samples.length
117+
const sorted = [...samples].sort((left, right) => left - right)
118+
const medianMs = sorted[Math.floor(sorted.length / 2)] ?? 0
119+
const minMs = sorted[0] ?? 0
120+
const maxMs = sorted[sorted.length - 1] ?? 0
121+
122+
return {
123+
label,
124+
totalMs,
125+
meanMs,
126+
medianMs,
127+
minMs,
128+
maxMs,
129+
}
130+
}
131+
132+
const formatMs = (value: number): string => `${value.toFixed(3)} ms`
133+
134+
const runLightingBenchmark = (argv: readonly string[]): void => {
135+
const warmupRounds = parsePositiveIntegerFlag(argv, 'warmup', DEFAULT_WARMUP_ROUNDS)
136+
const measuredRounds = parsePositiveIntegerFlag(argv, 'rounds', DEFAULT_MEASURED_ROUNDS)
137+
const seed = parsePositiveIntegerFlag(argv, 'seed', DEFAULT_SEED)
138+
const fixtureCount = parsePositiveIntegerFlag(argv, 'fixtures', DEFAULT_FIXTURE_COORDS.length)
139+
const fixtureCoords = DEFAULT_FIXTURE_COORDS.slice(0, fixtureCount)
140+
if (fixtureCoords.length === 0) {
141+
throw new Error('Lighting benchmark requires at least one fixture chunk.')
142+
}
143+
144+
const fixtureChunks = fixtureCoords.map((coord) => createFixtureChunk(coord, seed))
145+
const fixtureInputs = fixtureChunks.map((chunk) => createLightingChunkInputFromChunk(chunk))
146+
const nativeBuffers = createBuffers(fixtureChunks.length)
147+
const nativeLighting = new LightingSystem()
148+
149+
const runNativeRound = (): void => {
150+
for (let index = 0; index < fixtureInputs.length; index += 1) {
151+
const chunk = fixtureInputs[index]!
152+
const buffers = nativeBuffers[index]!
153+
buffers.sky.fill(0)
154+
buffers.block.fill(0)
155+
nativeLighting.relightChunk(chunk, buffers)
156+
}
157+
}
158+
159+
runNativeRound()
160+
161+
const native = measure('native', runNativeRound, warmupRounds, measuredRounds)
162+
const perChunkNativeMs = native.meanMs / fixtureChunks.length
163+
164+
console.log('Lighting benchmark')
165+
console.log(
166+
`Fixture: ${fixtureChunks.length} generated chunks, seed ${seed}, ${warmupRounds} warmup round(s), ${measuredRounds} measured round(s)`,
167+
)
168+
console.log(`Native mean: ${formatMs(native.meanMs)} (${formatMs(perChunkNativeMs)} per chunk)`)
169+
console.log(
170+
`Native range: ${formatMs(native.minMs)} min / ${formatMs(native.medianMs)} median / ${formatMs(native.maxMs)} max`,
171+
)
172+
}
173+
174+
export const lightingBenchmark: CliBenchmark = {
175+
name: 'lighting',
176+
description: 'Measure native chunk relight throughput on deterministic generated chunk fixtures.',
177+
run: runLightingBenchmark,
178+
}

apps/cli/src/benchmarks/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface CliBenchmark {
2+
name: string
3+
description: string
4+
run(argv: readonly string[]): Promise<void> | void
5+
}

apps/cli/src/build-native.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { projectRoot } from './paths.ts'
77
const GLFW_VERSION = '3.4'
88
const GLFW_SOURCE_ARCHIVE_URL = `https://github.com/glfw/glfw/releases/download/${GLFW_VERSION}/glfw-${GLFW_VERSION}.zip`
99
const GLFW_SOURCE_ARCHIVE_ROOT = `glfw-${GLFW_VERSION}`
10+
const lightingOutputPath = join(projectRoot, 'native', 'liblighting.dylib')
1011

1112
interface GlfwBuildInput {
1213
includeDir: string
@@ -192,3 +193,20 @@ const command = [
192193

193194
runCommand(command, 'Building Craftvale native bridge')
194195
console.log(`Built ${outputPath}`)
196+
runCommand(
197+
[
198+
'clang',
199+
'-std=c11',
200+
'-O3',
201+
'-Wall',
202+
'-Wextra',
203+
'-dynamiclib',
204+
`-mmacosx-version-min=${macosDeploymentTarget}`,
205+
join(projectRoot, 'native', 'lighting_relight.c'),
206+
join(projectRoot, 'native', 'lighting_borders.c'),
207+
'-o',
208+
lightingOutputPath,
209+
],
210+
'Building Craftvale native lighting module',
211+
)
212+
console.log(`Built ${lightingOutputPath}`)

0 commit comments

Comments
 (0)