Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
489c8cc
feat(render): tint entities from sampled block and sky light
sandexzx Sep 1, 2026
6de35a3
chore: format prittier
sandexzx Sep 1, 2026
cec0f45
feat(wasm-mesher): merge partial update_light without baking omitted …
sandexzx Sep 12, 2026
49e128c
feat(wasm-mesher): treat light seed as a candidate, not an emitter
sandexzx Sep 12, 2026
40461b3
feat(light): generate 1.17.1 per-state tables from vanilla Blocks.java
sandexzx Sep 12, 2026
ecf9d10
feat(wasm-mesher): recompute sky light so roofs actually darken
sandexzx Sep 12, 2026
e740f11
feat(renderer): wire client light owner behind enableClientLightOwner…
sandexzx Sep 12, 2026
4cf579d
feat(wasm-mesher): use raw lightBlock and 1.17.1 face-occlusion
sandexzx Sep 12, 2026
df0f93b
fix(wasm-mesher): cfg(test) on helper so cargo test sees TORCH/STONE
sandexzx Sep 12, 2026
d983d1b
fix(light-owner): yield during prep and require worker ready
sandexzx Sep 12, 2026
d3e2657
fix(light-owner): accept meshes by section revision, not a global ver…
sandexzx Sep 13, 2026
881b1ac
fix(wasm-mesher): activate the light solver on topology and availability
sandexzx Sep 13, 2026
12bc0a3
fix(wasm-mesher): memoize sky source-scan per (x,z)
sandexzx Sep 13, 2026
fc14e56
fix(wasm-mesher): admit one column per light batch
sandexzx Sep 13, 2026
de69174
fix(light-owner): unpack column sections without per-cell getBlockSta…
sandexzx Sep 13, 2026
25dc61b
fix(light-owner): skip trailing remesh wait and continue owner steps …
sandexzx Sep 13, 2026
cd35231
fix(renderer): install face-adjacent section remeshes as one group
sandexzx Sep 13, 2026
248ef0e
test(wasm-mesher): bench the cost of one block edit by column-mesh path
sandexzx Sep 13, 2026
8f6b2d7
fix(renderer): keep displayed GPU slots during remesh and add an opt-…
sandexzx Sep 13, 2026
ec91786
fix(renderer): version owner meshes and keep server light off the dis…
sandexzx Sep 13, 2026
879d982
fix(renderer): mesh player edits ahead of bulk light and stamp only s…
sandexzx Sep 21, 2026
a29e804
fix(renderer): start the client light owner only on 1.17.1
sandexzx Sep 21, 2026
5f6df3e
fix(test): satisfy tsc on light-owner and light-table tests
sandexzx Sep 21, 2026
465cf22
fix: keep newer-version lighting opt-in
sandexzx Sep 22, 2026
99097f3
Merge branch 'feat/entity-block-lighting' into feat/client-lighting
sandexzx Sep 22, 2026
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
6 changes: 5 additions & 1 deletion scripts/buildMesherWorker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ const buildOptions = createWorkerBuildOptions({
// Flatten output paths so worker bundles land at dist/mesherWasm.js (web-client contract)
// instead of dist/wasm-mesher/worker/mesherWasm.js.
entryNames: '[name]',
entryPoints: [path.join(rootDir, './src/wasm-mesher/worker/mesherWasm.ts'), path.join(rootDir, './src/mesher-legacy/mesher.ts')],
entryPoints: [
path.join(rootDir, './src/wasm-mesher/worker/mesherWasm.ts'),
path.join(rootDir, './src/mesher-legacy/mesher.ts'),
path.join(rootDir, './src/wasm-mesher/worker/lightOwnerWorker.ts')
],
plugins: [wasmPlugin]
}
})
Expand Down
259 changes: 259 additions & 0 deletions scripts/generate-light-tables-1171.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import MinecraftData from 'minecraft-data'

const VERSION = '1.17.1'
const WORLD_VERSION = 2730
const VANILLA_COMMIT = '72447031d70fca0cfc9087de2704da83bc12fa79'
const FACE_PROPS = ['down', 'east', 'north', 'south', 'up', 'west']

const here = dirname(fileURLToPath(import.meta.url))
const root = join(here, '..')
const blocksJavaPath = join(root, 'src/lib/vanilla-1.17.1/Blocks.java')
const outPath = join(root, 'src/lib/lightTables1171.generated.json')

export function matchParens(text, openIdx) {
let depth = 0
for (let i = openIdx; i < text.length; i++) {
const ch = text[i]
if (ch === '(') depth++
else if (ch === ')') {
depth--
if (depth === 0) return text.slice(openIdx + 1, i)
}
}
throw new Error('unbalanced parentheses')
}

export function parseEmissionRules(blocksJava) {
const rules = new Map()
let i = 0
while (true) {
const j = blocksJava.indexOf('register(', i)
if (j < 0) break
if (blocksJava.startsWith('String', j + 9)) {
i = j + 9
continue
}
const inner = matchParens(blocksJava, j + 8)
const nameMatch = inner.match(/^\s*"([^"]+)"/)
if (nameMatch) {
const lightAt = inner.indexOf('.lightLevel(')
let kind = { type: 'zero' }
if (lightAt >= 0) {
const arg = matchParens(inner, lightAt + '.lightLevel'.length)
.replace(/\s+/g, ' ')
.trim()
kind = classifyLightArg(arg)
}
rules.set(nameMatch[1], kind)
}
i = j + 9
}
return rules
}

function classifyLightArg(arg) {
const constant = arg.match(/^blockStatex -> (\d+)$/)
if (constant) return { type: 'const', value: Number(constant[1]) }
const lit = arg.match(/^litBlockEmission\((\d+)\)$/)
if (lit) return { type: 'lit', value: Number(lit[1]) }
if (arg === 'CandleBlock.LIGHT_EMISSION') return { type: 'candle' }
if (arg === 'LightBlock.LIGHT_EMISSION') return { type: 'light' }
if (arg === 'CaveVines.emission(14)') return { type: 'berries', value: 14 }
if (arg === 'GlowLichenBlock.emission(7)') return { type: 'anyFace', value: 7 }
if (arg === 'blockStatex -> RespawnAnchorBlock.getScaledChargeLevel(blockStatex, 15)') {
return { type: 'respawnAnchor' }
}
if (arg.includes('SeaPickleBlock.isDead')) return { type: 'seaPickle' }
throw new Error(`unknown lightLevel argument: ${arg}`)
}

function propsAt(block, stateId) {
let data = stateId - (block.minStateId ?? block.defaultState)
const out = {}
const states = block.states ?? []
for (let i = states.length - 1; i >= 0; i--) {
const prop = states[i]
const idx = data % prop.num_values
data = Math.floor(data / prop.num_values)
if (prop.type === 'bool') out[prop.name] = idx === 0
else if (prop.values) out[prop.name] = prop.values[idx]
else out[prop.name] = idx
}
return out
}

function emissionFor(rule, props) {
switch (rule.type) {
case 'zero':
return 0
case 'const':
return rule.value
case 'lit':
return props.lit === true ? rule.value : 0
case 'candle':
return props.lit === true ? 3 * Number(props.candles) : 0
case 'light':
return Number(props.level)
case 'berries':
return props.berries === true ? rule.value : 0
case 'anyFace':
return FACE_PROPS.some(face => props[face] === true) ? rule.value : 0
case 'respawnAnchor':
return Math.floor((Number(props.charges) / 4) * 15)
case 'seaPickle':
return props.waterlogged === true ? 3 + 3 * Number(props.pickles) : 0
default:
throw new Error(`unhandled rule ${rule.type}`)
}
}

function isSlabName(name) {
return name.endsWith('_slab') || name === 'petrified_oak_slab'
}

function isStairsName(name) {
return name.endsWith('_stairs')
}

function isLeavesName(name) {
return name.endsWith('_leaves')
}

function isLiquidName(name) {
return name === 'water' || name === 'lava' || name === 'bubble_column'
}

/** Raw 1.17.1 getLightBlock. Decay opacity is max(1, this); sky source stops when this != 0. */
function lightBlockFor(block, props) {
const name = block.name
if (name === 'tinted_glass') return 15
if (isLeavesName(name)) return 1
if (isLiquidName(name)) return 1
if (name === 'barrier') return 1
if (isSlabName(name)) {
if (props.type === 'double') return 15
return props.waterlogged === true ? 1 : 0
}
if (isStairsName(name)) {
return props.waterlogged === true ? 1 : 0
}
if (name === 'snow') {
return Number(props.layers) === 8 ? 15 : 0
}
if ((name === 'piston' || name === 'sticky_piston') && props.extended === true) {
return 0
}
if (name === 'glass' || name.endsWith('_stained_glass')) return 0
const fl = block.filterLight
if (typeof fl === 'number') return Math.max(0, Math.min(15, fl))
if (!block.transparent && block.boundingBox === 'block') return 15
return 0
}

const UPPER = {
north: 0x30,
south: 0xc0,
west: 0x50,
east: 0xa0
}
const CCW = { north: 'west', west: 'south', south: 'east', east: 'north' }
const CW = { north: 'east', east: 'south', south: 'west', west: 'north' }
const OUTER_OCTANT = {
north_west: 0x10,
north_east: 0x20,
south_west: 0x40,
south_east: 0x80
}

function invertY(occ) {
return ((occ & 0x0f) << 4) | ((occ & 0xf0) >> 4)
}

function stairOccupancy(props) {
const facing = String(props.facing)
const shape = String(props.shape)
const base = 0x0f
let occ = base
if (shape === 'straight') occ |= UPPER[facing] ?? 0
else if (shape === 'inner_left') occ |= (UPPER[facing] ?? 0) | (UPPER[CCW[facing]] ?? 0)
else if (shape === 'inner_right') occ |= (UPPER[facing] ?? 0) | (UPPER[CW[facing]] ?? 0)
else if (shape === 'outer_left') occ |= OUTER_OCTANT[`${facing}_${CCW[facing]}`] ?? OUTER_OCTANT[`${CCW[facing]}_${facing}`] ?? 0
else if (shape === 'outer_right') occ |= OUTER_OCTANT[`${facing}_${CW[facing]}`] ?? OUTER_OCTANT[`${CW[facing]}_${facing}`] ?? 0
return props.half === 'top' ? invertY(occ) : occ
}

/**
* 2×2×2 occupancy for 1.17.1 useShapeForLightOcclusion blocks.
* Full cubes stay 0: they block via lightBlock 15, and vanilla isEmptyShape is true
* unless useShapeForLightOcclusion. Glass/leaves: noOcclusion → 0.
*/
function occupancyFor(block, props) {
const name = block.name
if (isSlabName(name)) {
if (props.type === 'double') return 0
return props.type === 'top' ? 0xf0 : 0x0f
}
if (isStairsName(name)) return stairOccupancy(props)
if (name === 'snow' && Number(props.layers) === 8) return 0xff
return 0
}

export function buildVanillaLightTables1171(blocksJava, mcData = MinecraftData(VERSION)) {
const rules = parseEmissionRules(blocksJava)
let maxState = 0
for (const block of mcData.blocksArray) {
const hi = block.maxStateId ?? block.defaultState
if (hi > maxState) maxState = hi
if (!rules.has(block.name)) {
throw new Error(`1.17.1 Blocks.java has no register() for ${block.name}`)
}
}
const emission = new Uint8Array(maxState + 1)
const opacity = new Uint8Array(maxState + 1)
const occupancy = new Uint8Array(maxState + 1)
for (const block of mcData.blocksArray) {
const rule = rules.get(block.name)
const lo = block.minStateId ?? block.defaultState
const hi = block.maxStateId ?? block.defaultState
for (let id = lo; id <= hi; id++) {
const props = propsAt(block, id)
emission[id] = Math.max(0, Math.min(15, emissionFor(rule, props)))
opacity[id] = lightBlockFor(block, props)
occupancy[id] = occupancyFor(block, props)
}
}
return {
version: VERSION,
vanillaWorldVersion: WORLD_VERSION,
source: 'vanilla-1.17.1-blocks-java',
vanillaCommit: VANILLA_COMMIT,
vanillaRegistryComplete: true,
emission,
opacity,
occupancy
}
}

function toArtifact(tables) {
return {
version: tables.version,
vanillaWorldVersion: tables.vanillaWorldVersion,
source: tables.source,
vanillaCommit: tables.vanillaCommit,
vanillaRegistryComplete: tables.vanillaRegistryComplete,
emissionB64: Buffer.from(tables.emission).toString('base64'),
opacityB64: Buffer.from(tables.opacity).toString('base64'),
occupancyB64: Buffer.from(tables.occupancy).toString('base64')
}
}

const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
if (isMain) {
const blocksJava = readFileSync(blocksJavaPath, 'utf8')
const tables = buildVanillaLightTables1171(blocksJava)
writeFileSync(outPath, `${JSON.stringify(toArtifact(tables), null, 2)}\n`)
console.log(`wrote ${outPath} states=${tables.emission.length}`)
}
24 changes: 24 additions & 0 deletions src/bundler/bundlePrepare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { bundlePrepareMesherWorkers, MESHER_DIST_FILES } from './bundlePrepare'

describe('MESHER_DIST_FILES', () => {
it('is the source of truth for lightOwnerWorker.js', () => {
expect(MESHER_DIST_FILES).toContain('lightOwnerWorker.js')
expect(MESHER_DIST_FILES).toContain('lightOwnerWorker.js.map')
})

it('copies lightOwnerWorker.js from renderer dist', async () => {
const mesherDistDir = await mkdtemp(join(tmpdir(), 'renderer-dist-'))
const outDir = await mkdtemp(join(tmpdir(), 'app-dist-'))
for (const name of MESHER_DIST_FILES) {
await writeFile(join(mesherDistDir, name), `asset:${name}`)
}

const copied = await bundlePrepareMesherWorkers({ cwd: outDir, mesherDistDir, outDir })
expect(copied.some(path => path.endsWith('lightOwnerWorker.js'))).toBe(true)
expect(await readFile(join(outDir, 'lightOwnerWorker.js'), 'utf8')).toBe('asset:lightOwnerWorker.js')
})
})
2 changes: 1 addition & 1 deletion src/bundler/bundlePrepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'
import path from 'node:path'

/** Worker-related basenames inside `minecraft-renderer`/`dist`; skipped if missing. */
export const MESHER_DIST_FILES = ['mesher.js', 'mesher.js.map', 'mesherWasm.js', 'mesherWasm.js.map'] as const
export const MESHER_DIST_FILES = ['mesher.js', 'mesher.js.map', 'mesherWasm.js', 'mesherWasm.js.map', 'lightOwnerWorker.js', 'lightOwnerWorker.js.map'] as const

export type BundlePrepareMesherOptions = {
cwd?: string
Expand Down
15 changes: 15 additions & 0 deletions src/graphicsBackend/appViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
import { WorldView, WorldProvider, WorldViewWorker } from '../worldView'
import { getInitialPlayerState } from './playerState'
import { defaultWorldRendererConfig, defaultGraphicsBackendConfig, getDefaultRendererState, WorldRendererConfig } from './config'
import { maybeEnableClientLightTraceFromLocation } from '../lib/clientLightTrace'
import { PlayerStateReactive } from '../playerState/playerState'
import { ResourcesManager, ResourcesManagerTransferred } from '../resourcesManager'
import { preloadMesherWorkerScript } from './preloadWorkers'
Expand Down Expand Up @@ -101,6 +102,20 @@ export class AppViewer {
...defaultWorldRendererConfig,
...options.rendererConfig
})
// Manual live-test hatch: `?clientLight=1` sets `enableClientLightOwner`. Spawn still
// requires session version 1.17.1 (`shouldSpawnClientLightOwner`); other versions keep
// server light. An explicit code override always wins; without a browser location this
// is a no-op (unit tests keep testing the default).
if (options.rendererConfig?.enableClientLightOwner === undefined && typeof location !== 'undefined') {
try {
if (new URLSearchParams(location.search).get('clientLight') === '1') {
this.inWorldRenderingConfig.enableClientLightOwner = true
}
} catch {
// ignore malformed URL in non-browser runtimes
}
}
maybeEnableClientLightTraceFromLocation()

const defaultState = getDefaultRendererState()
this.rendererState = defaultState.reactive
Expand Down
5 changes: 5 additions & 0 deletions src/graphicsBackend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export const defaultWorldRendererConfig = {
* remeshing (change worker). When true, initial chunk meshing is
* distributed only across workers[0 .. n-2]. */
dedicatedChangeWorker: false,
/**
* Dedicated WASM light-owner worker. Default OFF: wiring exists so a
* human can flip this for live 1.17.1 runs. Do not enable for players.
*/
enableClientLightOwner: false,

// Rendering engine settings
/** Face shading: vanilla Minecraft vs higher-contrast client look */
Expand Down
50 changes: 50 additions & 0 deletions src/graphicsBackend/rendererDefaultOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { migrateRendererOptions, RENDERER_DEFAULT_OPTIONS, resolveEnableLighting } from './rendererDefaultOptions'

describe('migrateRendererOptions', () => {
it('drops the leaked migration flag so it is not a user option', () => {
const saved: Record<string, unknown> = {
newVersionsLighting: false,
migratedNewVersionsLightingDefault: true
}
migrateRendererOptions(saved)
expect(saved.newVersionsLighting).toBe(false)
expect(saved).not.toHaveProperty('migratedNewVersionsLightingDefault')
})
})

describe('newer-version lighting defaults', () => {
it('keeps lighting opt-in on 1.13+', () => {
expect(RENDERER_DEFAULT_OPTIONS.newVersionsLighting).toBe(false)
expect(resolveEnableLighting(RENDERER_DEFAULT_OPTIONS.newVersionsLighting, true)).toBe(false)
})

it.each([false, true])('preserves a saved lighting preference (%s)', value => {
const saved: Record<string, unknown> = { newVersionsLighting: value }
migrateRendererOptions(saved)
expect(saved.newVersionsLighting).toBe(value)
})

it('does not invent a stored value when the key was never saved', () => {
const saved: Record<string, unknown> = {}
migrateRendererOptions(saved)
expect(saved).not.toHaveProperty('newVersionsLighting')
})
})

describe('resolveEnableLighting', () => {
it('follows the option on 1.13+ (blockStateId)', () => {
expect(resolveEnableLighting(true, true)).toBe(true)
expect(resolveEnableLighting(false, true)).toBe(false)
})

it('always enables lighting on pre-1.13', () => {
expect(resolveEnableLighting(false, false)).toBe(true)
expect(resolveEnableLighting(true, false)).toBe(true)
})

it('treats a missing protocol probe as pre-1.13 so a menu toggle cannot throw', () => {
expect(resolveEnableLighting(false, undefined)).toBe(true)
expect(resolveEnableLighting(true, undefined)).toBe(true)
})
})
Loading
Loading