From 261015e7ad13e1540b956085f54d292e79179c46 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 11 Jul 2026 16:29:14 +0200 Subject: [PATCH 1/3] fix(scene): make setSceneNodeTransform/updateSceneNodeGeometry reachable from TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entry points take their arrays through i64 pointer params, which Perry 0.5.x refuses to accept from a `number[]` ("Expected safe integer for native i64 parameter"). The pointer-based functions were therefore dead from TypeScript, as the doc comment on setSceneNodeTransform already admitted. Meshes had long since routed around this via the bloom_mesh_scratch_* buffers; the scene-graph pair never got the same treatment because no game needed it — the shooter places nodes with the all-scalar setSceneNodeTrs. The world editor does need them: it applies full 4x4 matrices (arbitrary rotation and non-uniform scale — a boundary wall is 40x4x0.5), and it re-meshes terrain on every brush stroke. Add two all-scalar entry points and route the wrappers through them: - bloom_scene_set_transform16 — 16 matrix scalars, stateless, column-major. - bloom_scene_update_geometry_scratch — reuses the mesh scratch buffers, with ModelManager::take_scratch_geometry to read them back out. Additive: the old pointer functions stay for ABI compatibility, and the shooter compiles and links unchanged. --- native/shared/src/ffi_core/models.rs | 53 ++++++++++++++++++++++++ native/shared/src/ffi_core/scene.rs | 34 ++++++++++++++++ native/shared/src/models.rs | 20 ++++++++++ package.json | 32 +++++++++++++++ src/scene/index.ts | 60 ++++++++++++++++++++++------ 5 files changed, 186 insertions(+), 13 deletions(-) diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index 9895127..ec14b58 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -450,6 +450,59 @@ macro_rules! __bloom_ffi_models { 0.0 } + // Array-free scene-geometry upload. Same motivation as the mesh scratch + // above: `bloom_scene_update_geometry` takes `i64` pointers, which + // Perry cannot produce from a `number[]`. Push the vertex floats and + // u32 indices through the mesh scratch, then re-upload them into an + // existing scene node (the editor's terrain re-meshes every brush + // stroke this way). + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_scene_update_geometry_scratch( + handle: f64, + vertex_count: f64, + index_count: f64, + ) { + $crate::ffi::guard("bloom_scene_update_geometry_scratch", move || { + let taken = engine() + .models + .take_scratch_geometry(vertex_count as u32, index_count as u32); + let (vert_floats, indices) = match taken { + Some(v) => v, + None => return, + }; + let nv = vertex_count as usize; + let mut vertices = Vec::with_capacity(nv); + for i in 0..nv { + let base = i * 12; + vertices.push($crate::renderer::Vertex3D { + position: [vert_floats[base], vert_floats[base + 1], vert_floats[base + 2]], + normal: [vert_floats[base + 3], vert_floats[base + 4], vert_floats[base + 5]], + color: [ + vert_floats[base + 6], + vert_floats[base + 7], + vert_floats[base + 8], + vert_floats[base + 9], + ], + uv: [vert_floats[base + 10], vert_floats[base + 11]], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + } + engine().scene.update_geometry(handle, vertices, indices); + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_scene_update_geometry_scratch( + _handle: f64, + _vertex_count: f64, + _index_count: f64, + ) { + $crate::ffi::feature_off_warn_once("bloom_scene_update_geometry_scratch", "models3d"); + } + // bloom_get_model_mesh_count [source: linux; gated: models3d] #[cfg(feature = "models3d")] #[no_mangle] diff --git a/native/shared/src/ffi_core/scene.rs b/native/shared/src/ffi_core/scene.rs index a66c564..bb43c00 100644 --- a/native/shared/src/ffi_core/scene.rs +++ b/native/shared/src/ffi_core/scene.rs @@ -97,6 +97,40 @@ macro_rules! __bloom_ffi_scene { }) } + // bloom_scene_set_transform16 — all-f64 variant of set_transform. + // + // Perry 0.5.x refuses to pass a JS `number[]` into an `i64` pointer + // param ("Expected safe integer for native i64 parameter"), so the + // pointer-taking `bloom_scene_set_transform` above is unreachable from + // TypeScript. Meshes already dodge this via the scratch buffers; a + // 4x4 matrix is small enough to just spell out as 16 scalars, which + // keeps this stateless. Column-major, same layout as the ptr version. + #[no_mangle] + #[allow(clippy::too_many_arguments)] + pub extern "C" fn bloom_scene_set_transform16( + handle: f64, + m0: f64, m1: f64, m2: f64, m3: f64, + m4: f64, m5: f64, m6: f64, m7: f64, + m8: f64, m9: f64, m10: f64, m11: f64, + m12: f64, m13: f64, m14: f64, m15: f64, + ) { + $crate::ffi::guard("bloom_scene_set_transform16", move || { + let s = [ + m0, m1, m2, m3, + m4, m5, m6, m7, + m8, m9, m10, m11, + m12, m13, m14, m15, + ]; + let mut mat = [[0.0f32; 4]; 4]; + for col in 0..4 { + for row in 0..4 { + mat[col][row] = s[col * 4 + row] as f32; + } + } + engine().scene.set_transform(handle, mat); + }) + } + // bloom_scene_update_geometry [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_update_geometry( diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index 7aede39..4bf7254 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -109,6 +109,26 @@ impl ModelManager { self.create_mesh(&verts, &inds) } + /// Take the scratch buffers as raw vertex floats + indices, for callers + /// that build something other than a Model out of them (the scene graph's + /// `update_geometry`). Same 12-floats-per-vertex layout as + /// `create_mesh_from_scratch`; returns None if the scratch is short. + pub fn take_scratch_geometry( + &self, + vertex_count: u32, + index_count: u32, + ) -> Option<(Vec, Vec)> { + let need_f = vertex_count as usize * 12; + let need_u = index_count as usize; + if vertex_count == 0 || self.scratch_f32.len() < need_f || self.scratch_u32.len() < need_u { + return None; + } + Some(( + self.scratch_f32[..need_f].to_vec(), + self.scratch_u32[..need_u].to_vec(), + )) + } + pub fn load_model(&mut self, file_data: &[u8]) -> f64 { match load_gltf(file_data) { Some(model) => self.models.alloc(model), diff --git a/package.json b/package.json index 8246851..475483a 100644 --- a/package.json +++ b/package.json @@ -1979,6 +1979,38 @@ ], "returns": "void" }, + { + "name": "bloom_scene_set_transform16", + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], + "returns": "void" + }, + { + "name": "bloom_scene_update_geometry_scratch", + "params": [ + "f64", + "f64", + "f64" + ], + "returns": "void" + }, { "name": "bloom_scene_set_lod", "params": [ diff --git a/src/scene/index.ts b/src/scene/index.ts index 7363eac..a082ca6 100644 --- a/src/scene/index.ts +++ b/src/scene/index.ts @@ -28,6 +28,24 @@ declare function bloom_scene_set_cast_shadow(handle: number, cast: number): void declare function bloom_scene_set_receive_shadow(handle: number, receive: number): void; declare function bloom_scene_set_parent(handle: number, parent: number): void; declare function bloom_scene_set_transform(handle: number, matrix: number): void; +// All-scalar transform + geometry entry points. Perry 0.5.x cannot pass a +// `number[]` into the i64 pointer params above, so these are the ones the +// wrappers actually call. +declare function bloom_scene_set_transform16( + handle: number, + m0: number, m1: number, m2: number, m3: number, + m4: number, m5: number, m6: number, m7: number, + m8: number, m9: number, m10: number, m11: number, + m12: number, m13: number, m14: number, m15: number, +): void; +declare function bloom_scene_update_geometry_scratch( + handle: number, + vertexCount: number, + indexCount: number, +): void; +declare function bloom_mesh_scratch_reset(): void; +declare function bloom_mesh_scratch_push_f32(v: number): void; +declare function bloom_mesh_scratch_push_u32(v: number): void; declare function bloom_scene_set_trs(handle: number, px: number, py: number, pz: number, yaw: number, scale: number): void; declare function bloom_scene_update_geometry( handle: number, @@ -203,14 +221,23 @@ export function setSceneNodeParent(handle: SceneNodeHandle, parent: SceneNodeHan * Set the 4x4 transform matrix for a scene node. * Matrix is in column-major order (same as Three.js/glm). * - * NOTE: this crosses the FFI as an array-into-i64-pointer, which Perry - * 0.5.x rejects at runtime ("Expected safe integer for native i64 - * parameter"). Until the scratch-buffer migration lands, prefer - * `setSceneNodeTrs` for position/yaw/scale placement — it is all-scalar - * and works on every Perry. + * Takes a column-major 16-element matrix, so arbitrary rotation and + * non-uniform scale are expressible (`setSceneNodeTrs` covers only + * position + yaw + uniform scale). + * + * The matrix crosses the FFI as 16 scalars rather than a pointer: Perry + * 0.5.x refuses to pass a `number[]` into an `i64` pointer param + * ("Expected safe integer for native i64 parameter"), which made the old + * pointer-based entry point unreachable from TypeScript. */ export function setSceneNodeTransform(handle: SceneNodeHandle, matrix: number[]): void { - bloom_scene_set_transform(handle, matrix as any); + bloom_scene_set_transform16( + handle, + matrix[0], matrix[1], matrix[2], matrix[3], + matrix[4], matrix[5], matrix[6], matrix[7], + matrix[8], matrix[9], matrix[10], matrix[11], + matrix[12], matrix[13], matrix[14], matrix[15], + ); } /** @@ -233,19 +260,26 @@ export function setSceneNodeTrs( * @param vertices — Flat array of vertex data. Each vertex has 12 floats: * [x, y, z, nx, ny, nz, r, g, b, a, u, v] * @param indices — Flat array of triangle indices (3 per triangle). + * + * Uploads through the mesh scratch buffers (one scalar per FFI call) for the + * same reason as `createMesh`: Perry 0.5.x rejects `number[]` in an `i64` + * pointer param. Cost is linear in the mesh size, so this is for + * edit-time re-meshing (terrain sculpting), not per-frame streaming. */ export function updateSceneNodeGeometry( handle: SceneNodeHandle, vertices: number[], indices: number[], ): void { - bloom_scene_update_geometry( - handle, - vertices as any, - vertices.length / 12, - indices as any, - indices.length, - ); + const vertexCount = Math.floor(vertices.length / 12); + const indexCount = indices.length; + if (vertexCount === 0 || indexCount === 0) return; + + bloom_mesh_scratch_reset(); + const floatCount = vertexCount * 12; + for (let i = 0; i < floatCount; i++) bloom_mesh_scratch_push_f32(vertices[i]); + for (let i = 0; i < indexCount; i++) bloom_mesh_scratch_push_u32(indices[i]); + bloom_scene_update_geometry_scratch(handle, vertexCount, indexCount); } /** From e287977de668578b6ca50e50ce8ef8ea2a2352fa Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 11 Jul 2026 16:40:49 +0200 Subject: [PATCH 2/3] feat(world): render water and rivers, shared by games and the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instantiateWorld pushed two "pending Q8/Q9" warnings instead of spawning water volumes and rivers. Everything needed had in fact shipped — the water material, the spline-ribbon generator — so the warnings were just stale. Add src/world/render.ts with spawnWaterVolume and spawnRiver, and call them from instantiateWorld. The editor's sync layer calls the same two helpers, so a river cannot render differently in the editor than in the game. The world schema stores colours as 0-1 floats while setSceneNodeWaterMaterial takes 0-255; that conversion now lives in one place. genMeshSplineRibbon turned out to be unreachable from TypeScript for the same reason setSceneNodeTransform was — its arrays cross the FFI as i64 pointers. Add bloom_gen_mesh_spline_ribbon_scratch, which reads the points and widths back out of the mesh scratch buffers. InstantiateResult reports waterHandles/riverHandles as arrays index-aligned with world.water/world.rivers, deliberately not Maps: it already carries one Map, and Perry 0.5.x miscompiles interfaces declaring more than one Map field. --- native/shared/src/ffi_core/models.rs | 27 ++++++++ native/shared/src/models.rs | 6 ++ package.json | 8 +++ src/models/index.ts | 20 +++++- src/world/index.ts | 2 + src/world/loader.ts | 42 ++++++++--- src/world/render.ts | 100 +++++++++++++++++++++++++++ 7 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 src/world/render.ts diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index ec14b58..da00527 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -652,6 +652,33 @@ macro_rules! __bloom_ffi_models { 0.0 } + // Array-free spline ribbon. Same reason as the mesh scratch above: the + // pointer form is unreachable from TypeScript (Perry won't pass a + // `number[]` into an i64 param). Push `point_count * 3` position floats + // followed by `width_count` width floats into the mesh scratch, then + // call this. + #[cfg(feature = "models3d")] + #[no_mangle] + pub extern "C" fn bloom_gen_mesh_spline_ribbon_scratch(point_count: f64, width_count: f64) -> f64 { + $crate::ffi::guard("bloom_gen_mesh_spline_ribbon_scratch", move || { + let n = point_count as usize; + let wn = width_count as usize; + let scratch = engine().models.scratch_floats(); + if n < 2 || wn == 0 || scratch.len() < n * 3 + wn { + return 0.0; + } + let points: Vec = scratch[..n * 3].to_vec(); + let widths: Vec = scratch[n * 3..n * 3 + wn].to_vec(); + engine().models.gen_mesh_spline_ribbon(&points, &widths) + }) + } + #[cfg(not(feature = "models3d"))] + #[no_mangle] + pub extern "C" fn bloom_gen_mesh_spline_ribbon_scratch(_point_count: f64, _width_count: f64) -> f64 { + $crate::ffi::feature_off_warn_once("bloom_gen_mesh_spline_ribbon_scratch", "models3d"); + 0.0 + } + // bloom_stage_model [source: linux; gated: models3d] #[cfg(feature = "models3d")] #[no_mangle] diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index 4bf7254..652322b 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -109,6 +109,12 @@ impl ModelManager { self.create_mesh(&verts, &inds) } + /// Read-only view of the f32 scratch buffer, for consumers that lay their + /// own data out in it (the spline ribbon packs positions then widths). + pub fn scratch_floats(&self) -> &[f32] { + &self.scratch_f32 + } + /// Take the scratch buffers as raw vertex floats + indices, for callers /// that build something other than a Model out of them (the scene graph's /// `update_geometry`). Same 12-floats-per-vertex layout as diff --git a/package.json b/package.json index 475483a..98644cb 100644 --- a/package.json +++ b/package.json @@ -2163,6 +2163,14 @@ ], "returns": "f64" }, + { + "name": "bloom_gen_mesh_spline_ribbon_scratch", + "params": [ + "f64", + "f64" + ], + "returns": "f64" + }, { "name": "bloom_load_render_texture", "params": [ diff --git a/src/models/index.ts b/src/models/index.ts index 0c37f6d..c67dd53 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -51,6 +51,7 @@ declare function bloom_set_directional_light(dx: number, dy: number, dz: number, declare function bloom_set_procedural_sky(enabled: number, rayleighDensity: number, mieDensity: number, groundAlbedo: number): void; declare function bloom_set_sun_direction(dx: number, dy: number, dz: number, intensity: number): void; declare function bloom_gen_mesh_spline_ribbon(pointsPtr: number, pointCount: number, widthsPtr: number, widthCount: number): number; +declare function bloom_gen_mesh_spline_ribbon_scratch(pointCount: number, widthCount: number): number; declare function bloom_get_model_mesh_count(handle: number): number; declare function bloom_get_model_material_count(handle: number): number; declare function bloom_get_model_bounds_min_x(handle: number): number; @@ -201,9 +202,24 @@ export function unloadModel(model: Model): void { * `widths` has one width per control point. * Returns a Model whose mesh is a smooth triangle-strip ribbon. */ +/** + * Build a ribbon mesh that follows a Catmull-Rom spline through `points` + * (flat x,y,z triples), with a per-point half-width from `widths`. + * + * Goes through the mesh scratch buffers — positions first, then widths — for + * the same reason as `createMesh`: Perry 0.5.x will not pass a `number[]` into + * an `i64` pointer param, which made the pointer form unreachable from TS. + */ export function genMeshSplineRibbon(points: number[], widths: number[]): Model { - const pointCount = points.length / 3; - const handle = bloom_gen_mesh_spline_ribbon(points as any, pointCount, widths as any, widths.length); + const pointCount = Math.floor(points.length / 3); + const widthCount = widths.length; + if (pointCount < 2 || widthCount === 0) return makeModel(0); + + bloom_mesh_scratch_reset(); + for (let i = 0; i < pointCount * 3; i++) bloom_mesh_scratch_push_f32(points[i]); + for (let i = 0; i < widthCount; i++) bloom_mesh_scratch_push_f32(widths[i]); + + const handle = bloom_gen_mesh_spline_ribbon_scratch(pointCount, widthCount); return makeModel(handle); } diff --git a/src/world/index.ts b/src/world/index.ts index e83190d..1b375e6 100644 --- a/src/world/index.ts +++ b/src/world/index.ts @@ -9,6 +9,7 @@ // - version.ts schema version + migration // - validate.ts runtime schema validation // - loader.ts loadWorld / instantiateWorld (reads JSON, spawns scene nodes) +// - render.ts spawnWaterVolume / spawnRiver (shared by games + editor) // - saver.ts saveWorld / savePrefab (writes JSON) // - prefab.ts loadPrefab / expandPrefab / cycle detection // - terrain.ts buildHeightmapMesh / sampleHeight / raycastTerrain / defaultTerrain @@ -17,6 +18,7 @@ export * from './types'; export * from './version'; export * from './validate'; export * from './loader'; +export * from './render'; export * from './saver'; export * from './prefab'; export * from './terrain'; diff --git a/src/world/loader.ts b/src/world/loader.ts index 442b616..f703e22 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -38,6 +38,7 @@ import { mat4Scale, } from '../math/index'; import { Mat4, Vec3 } from '../core/types'; +import { spawnWaterVolume, spawnRiver } from './render'; import { WorldData, EntityData, @@ -84,6 +85,13 @@ export interface InstantiateResult { // Scene node handle for the terrain mesh, or 0 if the world has no terrain. terrainHandle: SceneNodeHandle; + // Handles for water volumes and rivers, index-aligned with `world.water` and + // `world.rivers`. Arrays rather than Maps on purpose: Perry 0.5.x miscompiles + // interfaces that declare more than one Map field (see the editor's + // docs/perry-map-size-av.md), and this interface already carries one. + waterHandles: SceneNodeHandle[]; + riverHandles: SceneNodeHandle[]; + // Non-fatal problems encountered during instantiation: missing models, // unresolved prefab references, cycles. The world still instantiates, // with the offending entities skipped. @@ -115,13 +123,15 @@ export function loadWorld(path: string): WorldData { } // Spawn scene nodes for every entity in the world and apply environment -// settings (lighting, shadows). Terrain, water, and rivers are also spawned -// when present. Water and river rendering depend on engine additions that -// land in later tasks; for now they are no-ops with TODO warnings. +// settings (lighting, shadows). Terrain, water volumes, and rivers are spawned +// when present — water and rivers via the shared helpers in ./render.ts, which +// the editor uses too so the two never diverge. export function instantiateWorld(world: WorldData, ctx: InstantiateContext): InstantiateResult { const result: InstantiateResult = { entityHandles: new Map(), terrainHandle: 0, + waterHandles: [], + riverHandles: [], warnings: [], }; @@ -149,15 +159,25 @@ export function instantiateWorld(world: WorldData, ctx: InstantiateContext): Ins } } - if (world.water.length > 0) { - result.warnings.push( - 'world has ' + world.water.length + ' water volume(s) — water rendering pending engine Q8 shader', - ); + // Water and rivers render through the shared helpers in ./render.ts, so a + // game and the editor produce identical geometry and materials. + for (let i = 0; i < world.water.length; i++) { + const handle = spawnWaterVolume(world.water[i]); + result.waterHandles.push(handle); + if (handle === 0) { + result.warnings.push('water volume "' + world.water[i].id + '" failed to spawn'); + } } - if (world.rivers.length > 0) { - result.warnings.push( - 'world has ' + world.rivers.length + ' river(s) — river rendering pending engine Q9 spline ribbon helper', - ); + + for (let i = 0; i < world.rivers.length; i++) { + const river = world.rivers[i]; + const handle = spawnRiver(river); + result.riverHandles.push(handle); + if (handle === 0) { + result.warnings.push( + 'river "' + river.id + '" failed to spawn (needs at least 2 control points)', + ); + } } return result; diff --git a/src/world/render.ts b/src/world/render.ts new file mode 100644 index 0000000..3fd8fc5 --- /dev/null +++ b/src/world/render.ts @@ -0,0 +1,100 @@ +// Shared spawn helpers for the parts of a world that are not entities: water +// volumes and rivers. +// +// Both the runtime loader (`instantiateWorld`, used by games) and the world +// editor call these, so a river looks the same in the editor as it does in the +// game. Anything that renders world data belongs here rather than in either +// consumer — the editor previously drew its own translucent debug cubes, which +// is exactly how the two drift apart. +// +// Colour convention: the world schema stores RGBA as 0-1 floats, while the +// scene API takes 0-255. The conversion lives here, once. + +import { + createSceneNode, setSceneNodeVisible, setSceneNodeWaterMaterial, + attachModelToNode, setSceneNodeColor, + SceneNodeHandle, +} from '../scene/index'; +import { genMeshCube, genMeshSplineRibbon } from '../models/index'; +import { setSceneNodeTransform } from '../scene/index'; +import { WaterVolume, RiverSpline } from './types'; + +// A water volume renders as a box whose *top face* sits at `surfaceHeight` +// (the schema's `center.y` positions the body of water; the surface is what the +// player sees and what the wave shader animates). +export function spawnWaterVolume(volume: WaterVolume): SceneNodeHandle { + const node = createSceneNode(); + const cube = genMeshCube(1, 1, 1); + attachModelToNode(node, cube.handle, 0); + + const sx = volume.size[0]; + const sy = volume.size[1]; + const sz = volume.size[2]; + + // Column-major TRS: scale on the diagonal, translation in the last column. + // Y is placed so the top face lands on surfaceHeight. + const cy = volume.surfaceHeight - sy / 2; + setSceneNodeTransform(node, [ + sx, 0, 0, 0, + 0, sy, 0, 0, + 0, 0, sz, 0, + volume.center[0], cy, volume.center[2], 1, + ]); + + const c = volume.color; + setSceneNodeWaterMaterial( + node, + volume.waveAmplitude, volume.waveSpeed, + c[0] * 255, c[1] * 255, c[2] * 255, c[3] * 255, + ); + setSceneNodeVisible(node, true); + return node; +} + +// A river renders as a ribbon mesh swept along its control points, dropped by +// `depth` so it sits in its channel rather than on top of the terrain. Widths +// are per control point; a river with fewer widths than points repeats the last. +export function spawnRiver(river: RiverSpline): SceneNodeHandle { + const pointCount = river.controlPoints.length; + if (pointCount < 2) return 0; + + const points: number[] = []; + for (let i = 0; i < pointCount; i++) { + const p = river.controlPoints[i]; + points.push(p[0]); + points.push(p[1] - river.depth); + points.push(p[2]); + } + + const widths: number[] = []; + for (let i = 0; i < pointCount; i++) { + const w = i < river.widths.length + ? river.widths[i] + : (river.widths.length > 0 ? river.widths[river.widths.length - 1] : 1); + widths.push(w); + } + + const ribbon = genMeshSplineRibbon(points, widths); + if (ribbon.handle === 0) return 0; + + const node = createSceneNode(); + attachModelToNode(node, ribbon.handle, 0); + + const c = river.color; + // Flow speed drives the same wave animation as a water volume; a river with + // no flow still ripples gently rather than reading as a flat plastic strip. + setSceneNodeWaterMaterial( + node, + 0.05, river.flowSpeed, + c[0] * 255, c[1] * 255, c[2] * 255, c[3] * 255, + ); + setSceneNodeVisible(node, true); + return node; +} + +// Editor-only: tint a water/river node to show selection. Games never call this. +export function setWaterHighlight(handle: SceneNodeHandle, selected: boolean): void { + if (selected) { + setSceneNodeColor(handle, 255, 220, 120, 255); + } +} From 19bd1e1c3071c11831a7d8cc70c116ff727eda16 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 11 Jul 2026 16:55:47 +0200 Subject: [PATCH 3/3] feat(world): point lights become first-class schema (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A light used to be an entity carrying userData.kind = "point_light" plus range/color/intensity strings — a private convention between one game and its baker. Anything that wasn't that game saw an entity with no model: the editor could not light its preview, and the next game would have re-invented the same convention. Sun, ambient, and fog were already first-class in `environment`. Point lights now sit beside them in a top-level `lights: LightData[]`. The line this draws, for the next schema question: lights are engine-universal (every renderer knows what a point light is), so they are schema. A spawner or a wave plan means nothing without the game, so it stays userData. - WORLD_SCHEMA_VERSION = 2, with a v1 -> v2 migration that lifts point_light entities into world.lights on load. Old worlds keep working untouched. - applyWorldLights(world) must be called every frame: the renderer clears its lighting block in begin_frame, the same reason games re-apply sun and ambient. Calling it once at load lights the world for exactly one frame. - validateWorld checks lights (duplicate ids, kind, vectors, numbers). --- src/world/loader.ts | 4 ++- src/world/render.ts | 24 ++++++++++++++-- src/world/types.ts | 26 ++++++++++++++++- src/world/validate.ts | 24 ++++++++++++++++ src/world/version.ts | 65 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 136 insertions(+), 7 deletions(-) diff --git a/src/world/loader.ts b/src/world/loader.ts index f703e22..6459226 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -40,6 +40,7 @@ import { import { Mat4, Vec3 } from '../core/types'; import { spawnWaterVolume, spawnRiver } from './render'; import { + WORLD_SCHEMA_VERSION, WorldData, EntityData, TransformData, @@ -355,7 +356,7 @@ function applyTint(node: SceneNodeHandle, tint: Vec4Lit): void { // File -> New. Games should prefer `loadWorld` from a file on disk. export function createEmptyWorld(id: string, name: string): WorldData { return { - schemaVersion: 1, + schemaVersion: WORLD_SCHEMA_VERSION, name: name, id: id, bounds: { min: [-50, -10, -50], max: [50, 50, 50] }, @@ -373,6 +374,7 @@ export function createEmptyWorld(id: string, name: string): WorldData { }, terrain: null, entities: [], + lights: [], water: [], rivers: [], metadata: {}, diff --git a/src/world/render.ts b/src/world/render.ts index 3fd8fc5..a13e7cf 100644 --- a/src/world/render.ts +++ b/src/world/render.ts @@ -12,12 +12,32 @@ import { createSceneNode, setSceneNodeVisible, setSceneNodeWaterMaterial, - attachModelToNode, setSceneNodeColor, + attachModelToNode, setSceneNodeColor, addPointLight, SceneNodeHandle, } from '../scene/index'; import { genMeshCube, genMeshSplineRibbon } from '../models/index'; import { setSceneNodeTransform } from '../scene/index'; -import { WaterVolume, RiverSpline } from './types'; +import { WorldData, WaterVolume, RiverSpline } from './types'; + +// Re-submit the world's point lights. +// +// MUST be called every frame, not once at load: the renderer clears its +// lighting block in begin_frame (the same reason games re-apply sun and ambient +// each frame). Calling it once at startup produces a world that is lit for +// exactly one frame and then goes dark. +// +// Colour components are 0-1 in both the schema and `addPointLight`. +export function applyWorldLights(world: WorldData): void { + for (let i = 0; i < world.lights.length; i++) { + const l = world.lights[i]; + addPointLight( + l.position[0], l.position[1], l.position[2], + l.range, + l.color[0], l.color[1], l.color[2], + l.intensity, + ); + } +} // A water volume renders as a box whose *top face* sits at `surfaceHeight` // (the schema's `center.y` positions the body of water; the surface is what the diff --git a/src/world/types.ts b/src/world/types.ts index 3d02383..e8923f8 100644 --- a/src/world/types.ts +++ b/src/world/types.ts @@ -5,7 +5,13 @@ // The engine's runtime `Vec3` / `Mat4` / `BoundingBox` types (in `core/types.ts`) // use a different shape; the loader in `./loader.ts` does the conversion. -export const WORLD_SCHEMA_VERSION = 1; +// v2 (2026-07-11) — lights became a first-class top-level array. Before that, +// games carried them as entities tagged `userData.kind === "point_light"`, so +// only the game knew they were lights: the editor could not light its preview, +// and every game re-invented the same convention. Sun/ambient/fog were already +// first-class in `environment`; point lights now sit beside them. +// `migrateWorldData` converts v1 point_light entities automatically. +export const WORLD_SCHEMA_VERSION = 2; // Literal types for JSON-friendly serialization. // Use `Vec3Lit` in serialized data; convert to engine `Vec3` at load time. @@ -25,11 +31,29 @@ export interface WorldData { environment: EnvironmentData; terrain: TerrainData | null; // null for games that don't use terrain. entities: EntityData[]; + lights: LightData[]; // Point lights. Sun/ambient live in `environment`. water: WaterVolume[]; rivers: RiverSpline[]; metadata: Record; // Game-specific extensibility (e.g. "gameId"). } +// A placed light. Only point lights for now — the sun and ambient are +// environment-wide and live in `EnvironmentData`, and spot lights are not yet +// in the renderer. +// +// Lights are engine-universal (every renderer knows what a point light is), +// which is why they are schema rather than `userData`: a spawner or a wave plan +// means nothing without the game, but a light means the same thing everywhere. +export interface LightData { + id: string; // Stable within the world file, e.g. "light_0001". + name: string; // Display name. + kind: "point"; + position: Vec3Lit; + color: Vec3Lit; // 0..1 RGB (the runtime API takes 0-255; loader converts). + intensity: number; + range: number; // World units; beyond this the light contributes nothing. +} + export interface Bounds { min: Vec3Lit; max: Vec3Lit; diff --git a/src/world/validate.ts b/src/world/validate.ts index 3e68239..81917e1 100644 --- a/src/world/validate.ts +++ b/src/world/validate.ts @@ -59,6 +59,30 @@ export function validateWorld(w: WorldData): ValidationResult { } } + if (!Array.isArray(w.lights)) { + errors.push('world.lights must be an array'); + } else { + const seenLightIds = new Set(); + for (let i = 0; i < w.lights.length; i++) { + const l = w.lights[i]; + const path = 'world.lights[' + i + ']'; + if (typeof l.id !== 'string' || l.id.length === 0) { + errors.push(path + '.id is missing'); + } else if (seenLightIds.has(l.id)) { + errors.push(path + '.id "' + l.id + '" is a duplicate'); + } else { + seenLightIds.add(l.id); + } + if (l.kind !== 'point') { + errors.push(path + '.kind must be "point"'); + } + checkVec3(errors, path + '.position', l.position); + checkVec3(errors, path + '.color', l.color); + if (typeof l.intensity !== 'number') errors.push(path + '.intensity must be a number'); + if (typeof l.range !== 'number') errors.push(path + '.range must be a number'); + } + } + if (!Array.isArray(w.water)) { errors.push('world.water must be an array'); } diff --git a/src/world/version.ts b/src/world/version.ts index 2138db7..839c08a 100644 --- a/src/world/version.ts +++ b/src/world/version.ts @@ -5,7 +5,7 @@ // migration step here. `migrateWorldData` and `migratePrefabData` are called // by the loader before handing data to the rest of the pipeline. -import { WORLD_SCHEMA_VERSION, WorldData, PrefabData } from './types'; +import { WORLD_SCHEMA_VERSION, WorldData, PrefabData, LightData, Vec3Lit } from './types'; // Migrate a parsed world document to the current schema version. Returns the // same object (mutated in place) for convenience. Logs a warning when the @@ -24,13 +24,72 @@ export function migrateWorldData(raw: WorldData): WorldData { } // from < WORLD_SCHEMA_VERSION. Apply migration steps in order. - // When bumping WORLD_SCHEMA_VERSION, add a step here of the form: - // if (raw.schemaVersion < N) { migrateWorldV(N-1, N, raw); raw.schemaVersion = N; } + + if (from < 2) { + migrateV1ToV2(raw); + } raw.schemaVersion = WORLD_SCHEMA_VERSION; return raw; } +// v1 → v2: lights become a top-level array. +// +// In v1 a light was an ordinary entity carrying `userData.kind = "point_light"` +// plus `range`, `color` ("r, g, b"), and `intensity` strings — a convention each +// game had to know about. Lift those entities into `world.lights` and drop them +// from `entities`, so the editor (and any other consumer) sees a light as a +// light. Worlds with no such entities just gain an empty array. +function migrateV1ToV2(raw: WorldData): void { + if (!raw.lights) raw.lights = []; + + const kept: typeof raw.entities = []; + + for (let i = 0; i < raw.entities.length; i++) { + const e = raw.entities[i]; + const kind = e.userData ? e.userData['kind'] : undefined; + if (kind !== 'point_light') { + kept.push(e); + continue; + } + + const light: LightData = { + id: e.id, + name: e.name, + kind: 'point', + position: [ + e.transform.position[0], + e.transform.position[1], + e.transform.position[2], + ], + color: parseColor(e.userData['color']), + intensity: parseNumber(e.userData['intensity'], 1), + range: parseNumber(e.userData['range'], 12), + }; + raw.lights.push(light); + } + + raw.entities = kept; +} + +// userData values are strings; "1.0, 0.85, 0.55" is the colour convention. +function parseColor(s: string | undefined): Vec3Lit { + if (s === undefined || s.length === 0) return [1, 1, 1]; + const parts = s.split(','); + if (parts.length !== 3) return [1, 1, 1]; + const r = parseFloat(parts[0]); + const g = parseFloat(parts[1]); + const b = parseFloat(parts[2]); + if (r !== r || g !== g || b !== b) return [1, 1, 1]; + return [r, g, b]; +} + +function parseNumber(s: string | undefined, fallback: number): number { + if (s === undefined || s.length === 0) return fallback; + const v = parseFloat(s); + return v === v ? v : fallback; +} + export function migratePrefabData(raw: PrefabData): PrefabData { const from = raw.schemaVersion | 0;