diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index 0f4baca26..da2ddd3a0 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -11,7 +11,7 @@ targets could not be built and are named below. | `src/audio/` | 1471 | 1301 | 88.44% | | `src/canvas/` | 1334 | 1110 | 83.21% | | `src/cli/` | 1691 | 1243 | 73.51% | -| `src/engine/` | 41646 | 35087 | 84.25% | +| `src/engine/` | 41711 | 35119 | 84.20% | | `src/fs/` | 235 | 189 | 80.43% | | `src/http/` | 410 | 377 | 91.95% | | `src/js/` | 2838 | 2230 | 78.58% | @@ -22,12 +22,12 @@ targets could not be built and are named below. | `src/storage/` | 370 | 329 | 88.92% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 10066 | 7724 | 76.73% | +| `src/webgpu/` | 10066 | 7733 | 76.82% | | `src/webtransport/` | 1391 | 1078 | 77.50% | -| `src/workers/` | 615 | 527 | 85.69% | -| **TOTAL** | **66966** | **54931** | **82.03%** | +| `src/workers/` | 615 | 524 | 85.20% | +| **TOTAL** | **67031** | **54969** | **82.01%** | -Source digest: `sha256:743fad9d2d06d67dd4c6b7c676a4dbe0cfd025e53b87660704f57a75ab99922e` +Source digest: `sha256:99b3899fe15cbc9a38bdfcb000497fdfa5f73b0a63d5a14ee4f20c7541f24fd3` The default `pnpm budgets` gate reads this committed measurement without configuring or compiling the native host. Any native source, native C++ test, CTest registration, or coverage aggregation diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 56050560b..6622f9ef2 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -2248,6 +2248,20 @@ "desktopGate": false, "required": false }, + { + "id": "material-lights", + "title": "a material lightsNode sums only the lights it lists (a camera-parented cockpit lit by its cabin lamp)", + "category": "lights", + "status": "implemented", + "scene": "conformance/scenes/shared/material-lights.js", + "browserRecipe": "webgpu", + "tolerance": { + "pixelMismatchRatio": 0.03, + "perceptualDeltaE": 6.0 + }, + "desktopGate": false, + "required": false + }, { "id": "compute-texture-load", "title": "compute kernel reading a float DataTexture only with textureLoad (no sampler bound)", diff --git a/packages/runtime-native/conformance/scenes/shared/material-lights.js b/packages/runtime-native/conformance/scenes/shared/material-lights.js new file mode 100644 index 000000000..0fbb34ec1 --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/material-lights.js @@ -0,0 +1,43 @@ +import * as THREE from "three/webgpu"; +import { lights } from "three/tsl"; +import { startVisualScene } from "./scene-support.js"; + +// NodeMaterial.lightsNode = lights([...]): a material sums only the lights it lists. A camera-parented cockpit lights +// its interior with its own cabin lamp while a strong sun and the ambient light the world keeps stay off it (the +// Starlance friction, 2026-11-05). Left: the world material (sun, ambient and cabin lamp). Right and the panel below +// the view: cabin lamp only. The sun's colour is blue so a leak onto the right knot shows as a hue shift. +export async function startScene(canvas, dimensions) { + return startVisualScene(canvas, dimensions, "material-lights", ({ scene, camera }) => { + const sun = new THREE.DirectionalLight(0xa0c0ff, 4); + sun.position.set(-3, 4, 2); + const ambient = new THREE.AmbientLight(0x406080, 0.6); + const cabin = new THREE.PointLight(0xffc070, 6, 6, 2); + cabin.position.set(0, 0.6, -0.4); + camera.add(cabin); + scene.add(sun, ambient, camera); + + const cabinOnly = lights([cabin]); + const geometry = new THREE.TorusKnotGeometry(0.42, 0.14, 96, 16); + const world = new THREE.Mesh( + geometry, + new THREE.MeshStandardNodeMaterial({ color: 0xd0d0d0, roughness: 0.45 }), + ); + world.position.x = -0.75; + const interior = new THREE.MeshStandardNodeMaterial({ color: 0xd0d0d0, roughness: 0.45 }); + interior.lightsNode = cabinOnly; + const inside = new THREE.Mesh(geometry, interior); + inside.position.x = 0.75; + inside.rotation.set(0.3, 0.45, 0); + world.rotation.copy(inside.rotation); + + // A dashboard panel parented to the camera, as a cockpit is: lit by the cabin lamp, never by the sun. + const panelMaterial = new THREE.MeshStandardNodeMaterial({ color: 0x808890, roughness: 0.6 }); + panelMaterial.lightsNode = cabinOnly; + const panel = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.25, 0.6), panelMaterial); + panel.position.set(0, -0.95, -1.6); + panel.rotation.x = 0.35; + camera.add(panel); + scene.add(world, inside); + return { world, inside, panel }; + }); +} diff --git a/packages/runtime-native/include/threenative/abi/tn_abi.h b/packages/runtime-native/include/threenative/abi/tn_abi.h index 6c072ec11..a82c81196 100644 --- a/packages/runtime-native/include/threenative/abi/tn_abi.h +++ b/packages/runtime-native/include/threenative/abi/tn_abi.h @@ -43,8 +43,8 @@ extern "C" { * of the catalog entries with status `supported`, sorted by code unit and joined with a single * newline, with no trailing newline. A module built against a different published surface hashes * differently, which is what TN_DIAG_CAPABILITY_MISMATCH reports. */ -#define TN_CAPABILITY_DIGEST 0x87e92d9f974ab8d0ull -#define TN_CAPABILITY_COUNT 160u +#define TN_CAPABILITY_DIGEST 0x60ebf4ccbbcd317full +#define TN_CAPABILITY_COUNT 161u typedef uint32_t tn_status_t; diff --git a/packages/runtime-native/src/adapters/v8/tsl.cpp b/packages/runtime-native/src/adapters/v8/tsl.cpp index f17f9e16b..8b0f4446c 100644 --- a/packages/runtime-native/src/adapters/v8/tsl.cpp +++ b/packages/runtime-native/src/adapters/v8/tsl.cpp @@ -140,7 +140,7 @@ abi::TslArg Tsl::argument(const std::string& name, int index, int count, v8::Loc // names; reflector takes its target and virtual camera. if ((index == 0 && (name == "pmremTexture" || name == "texture3D" || name == "storageTexture" || name == "textureStore" || name == "storage" || name == "lightShadowMatrix")) || - (index < 2 && (name == "reflector" || name == "__rtt"))) { + name == "lights" || (index < 2 && (name == "reflector" || name == "__rtt"))) { tn_handle_t handle{}; const binding::Object* object = engineObject && engineObject(value, handle) ? abi::objectOf(handle) : nullptr; if (object == nullptr) throw std::runtime_error(name + " needs an engine object as argument " + std::to_string(index)); @@ -692,7 +692,7 @@ void Tsl::install(v8::Local context, v8::Local target) "dFdx", "dFdy", "lengthSq", "viewportSharedTexture", "viewportDepthTexture", "linearDepth", "toneMappingColor", "ao", "ssr", "denoise", "smaa", "bloom", "traa", "film", "lut3D", "renderOutput", "pipelineOutput", "struct", "luminance", "viewZToOrthographicDepth", "viewZToPerspectiveDepth", "logarithmicDepthToViewZ", "oneMinus", "varying", "setUniform", "mx_noise_float", "mx_worley_noise_float", "mx_worley_noise_vec2", "pmremTexture", "reflector", "transformDirection", - "atomicAdd", "atomicStore", "atomicLoad", "lightShadowMatrix", "wgslFunction", "texture3D", "getViewPosition", "transformNormalToView", "property", "radians", "degrees", "inverseSqrt", "ceil", "sinh", "cosh", "tan", "tanh", "asin", "asinh", "acos", "acosh", "atanh", "sign", "round", "reciprocal", "trunc", "transpose", "determinant", "inverse", "difference", "pow2", "pow3", "pow4", "transformNormalByViewMatrix", "transformNormalByInverseViewMatrix", "cbrt", "saturate", "refract", "faceForward", "rand", "mixElement", "smoothstepElement", "stepElement", "faceforward", "inversesqrt", "notEqual", "xor", "bitAnd", "bitNot", "bitOr", "bitXor", "shiftLeft", "shiftRight", "incrementBefore", "decrementBefore", "increment", "decrement", "mat3", "mat4"}) + "atomicAdd", "atomicStore", "atomicLoad", "lightShadowMatrix", "lights", "wgslFunction", "texture3D", "getViewPosition", "transformNormalToView", "property", "radians", "degrees", "inverseSqrt", "ceil", "sinh", "cosh", "tan", "tanh", "asin", "asinh", "acos", "acosh", "atanh", "sign", "round", "reciprocal", "trunc", "transpose", "determinant", "inverse", "difference", "pow2", "pow3", "pow4", "transformNormalByViewMatrix", "transformNormalByInverseViewMatrix", "cbrt", "saturate", "refract", "faceForward", "rand", "mixElement", "smoothstepElement", "stepElement", "faceforward", "inversesqrt", "notEqual", "xor", "bitAnd", "bitNot", "bitOr", "bitXor", "shiftLeft", "shiftRight", "incrementBefore", "decrementBefore", "increment", "decrement", "mat3", "mat4"}) module->Set(context, str(isolate_, name), function(context, name, false)->GetFunction(context).ToLocalChecked()) .Check(); for (auto& [name, node] : abi::tslConstants()) diff --git a/packages/runtime-native/src/engine/abi/bindings_material.cpp b/packages/runtime-native/src/engine/abi/bindings_material.cpp index 924902808..4a1cbe389 100644 --- a/packages/runtime-native/src/engine/abi/bindings_material.cpp +++ b/packages/runtime-native/src/engine/abi/bindings_material.cpp @@ -352,6 +352,14 @@ void registerMeshMaterial(ClassBinding& b, MaterialType type, bool node = false, as(self)->needsUpdate(); }; } + b.getters["lightsNode"] = [](void* self) { return Value::shaderNode(as(self)->nodes.lightsNode); }; + b.setters["lightsNode"] = [](void* self, const Value& v) { + if (v.kind != Value::Kind::Null && (v.kind != Value::Kind::ShaderNode || !v.node || + v.node->kind != shader::graph::Kind::Struct || v.node->name != "lights")) + throw Unsupported{"lightsNode must be a lights([...]) node or null"}; + as(self)->nodes.lightsNode = v.node; + as(self)->needsUpdate(); + }; } // ------------------------------------------------------------------------------- lights diff --git a/packages/runtime-native/src/engine/abi/tsl_call.cpp b/packages/runtime-native/src/engine/abi/tsl_call.cpp index c137ea570..b7f8d104a 100644 --- a/packages/runtime-native/src/engine/abi/tsl_call.cpp +++ b/packages/runtime-native/src/engine/abi/tsl_call.cpp @@ -113,6 +113,21 @@ g::Node tslCall(const std::string& name, const TslArg* receiver, const std::vect node->name = "authoredLightShadowMatrix" + std::to_string(light->id()); return node; } + if (!method && name == "lights") { + // three's lights([...]): a LightsNode of exactly these lights, for NodeMaterial.lightsNode. The JS side spreads the array. + auto node = std::make_shared(); + node->kind = g::Kind::Struct; node->name = "lights"; + for (const TslArg& light : args) { + if (light.kind != TslArg::Kind::Object || !light.object || + (light.cls != "AmbientLight" && light.cls != "DirectionalLight" && light.cls != "HemisphereLight" && + light.cls != "PointLight" && light.cls != "SpotLight")) + throw std::runtime_error("lights requires an array of lights"); + auto entry = std::make_shared(); + entry->object = light.object; + node->args.push_back(std::move(entry)); + } + return node; + } if (!method && name == "wgslFunction") { if (args.size() < 3 || args.size() > 7) throw std::runtime_error("wgslFn requires source, name, result type and up to four input types"); auto function = std::make_shared(); diff --git a/packages/runtime-native/src/engine/player/core-tsl.mjs b/packages/runtime-native/src/engine/player/core-tsl.mjs index bcc5406c0..2346b5031 100644 --- a/packages/runtime-native/src/engine/player/core-tsl.mjs +++ b/packages/runtime-native/src/engine/player/core-tsl.mjs @@ -25,6 +25,8 @@ export const syncUniforms = live.sync; export const { float, int, uint, vec2, vec3, color } = constants; export const vec4 = markNormalTarget(constants.vec4, globalThis.tsl.normalView); export const Fn = withFnBuilder(globalThis.tsl.Fn); +// three's lights([...]) takes one array; each light crosses as its own engine object. +export const lights = (list = []) => globalThis.tsl.lights(...list); export const { atomicAdd, atomicStore, atomicLoad, Break, Continue, Return, Discard, log, lightShadowMatrix, attribute, uv, renderOutput, If, Loop, luminance, viewZToOrthographicDepth, viewZToPerspectiveDepth, logarithmicDepthToViewZ, vertexIndex, add, sub, mul, div, negate, lessThan, greaterThan, equal, lessThanEqual, greaterThanEqual, and, or, not, all, any, abs, sin, cos, diff --git a/packages/runtime-native/src/engine/renderer/render_database.cpp b/packages/runtime-native/src/engine/renderer/render_database.cpp index 7e8617750..6331b0d64 100644 --- a/packages/runtime-native/src/engine/renderer/render_database.cpp +++ b/packages/runtime-native/src/engine/renderer/render_database.cpp @@ -1023,6 +1023,7 @@ void RenderDatabase::project(Object3D& object, const Camera& camera, std::vector const auto& l = static_cast(object); for (int c = 0; c < 3; ++c) lights.ambient[c] += scaled(l.color, l.intensity)[c]; + lights.ambients.emplace_back(object.id(), scaled(l.color, l.intensity)); } else if (mainPass && type == "DirectionalLight") { auto& l = static_cast(object); // its shadow camera moves, as three's does // three reads light.target.matrixWorld as it stands: only a scene traversal refreshes it, so a target @@ -1082,6 +1083,7 @@ void RenderDatabase::project(Object3D& object, const Camera& camera, std::vector lights.hemisphereSky = scaled(l.color, l.intensity); lights.hemisphereGround = scaled(l.groundColor, l.intensity); lights.hemisphereUp = normalized(worldPosition(l)); + lights.hemisphereId = object.id(); } } } else if (!lightsOnly && cached && cached->meshId == object.id()) { diff --git a/packages/runtime-native/src/engine/renderer/renderer.cpp b/packages/runtime-native/src/engine/renderer/renderer.cpp index 0c938badc..212831eb1 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer.cpp @@ -2579,7 +2579,8 @@ Renderer::Program* Renderer::program(MaterialKind kind, const shader::VertexVari if (backgroundNode) throw std::runtime_error(message); return refuse(message); }; - const shader::LightLayout layout{lights}; + const std::size_t selection = lights.find('#'); // a lightsNode program: kinds, then `#` and the listed ids + const shader::LightLayout layout{lights.substr(0, selection)}; shader::StandardPrograms source; try { switch (kind) { @@ -2606,7 +2607,12 @@ Renderer::Program* Renderer::program(MaterialKind kind, const shader::VertexVari return refuseBuild(errors.empty() ? "no reason" : errors.front()); } Program& built = add(key, std::move(vertex), std::move(fragment)); - built.kind = kind; built.variant = vv; built.lightKinds = lights; + built.kind = kind; built.variant = vv; built.lightKinds = layout.kinds; + if (selection != std::string::npos) { + built.selective = true; + for (std::size_t at = selection + 1; at < lights.size(); at = lights.find(',', at) + 1) + built.lightSelection.push_back(std::stoull(lights.substr(at))); + } return &built; } @@ -3584,12 +3590,33 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS const uint64_t lightKindsKey = hashBytes(lightKinds.data(), lightKinds.size()); const uint64_t unshadowedKindsKey = hashBytes(unshadowedKinds.data(), unshadowedKinds.size()); const uint64_t basicLightsKey = hashBytes("", 0) ^ 0x5bf03635ull; - const auto drawLightsKey = [&](const DrawItem& item) { + // NodeMaterial.lightsNode: the kinds with every light it does not list marked excluded, then `#` and the ids it + // lists. The ids key the program, whose frame slab then holds only their ambient and hemisphere terms. + // ponytail: rebuilt per draw each frame; cache it on the draw when many materials carry a lightsNode. + const auto selectiveLights = [&](const DrawItem& item) { + const shader::graph::Node& node = item.nodes ? item.nodes->lightsNode : shader::graph::Node{}; + std::string out; + if (!node || item.kind == MaterialKind::Basic) return out; + std::vector ids; + for (const auto& entry : node->args) + ids.push_back(static_cast(entry->object.get())->id()); + std::sort(ids.begin(), ids.end()); + ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + const auto listed = [&](uint64_t id) { return std::binary_search(ids.begin(), ids.end(), id); }; + out = item.receiveShadow ? lightKinds : unshadowedKinds; + for (std::size_t i = 0; i < lights.direct.size(); ++i) + if (!listed(lights.direct[i].id)) out[i] = shader::LightLayout::kExcluded; + out += '#'; + for (const uint64_t id : ids) out += std::to_string(id) + ','; + return out; + }; + const auto drawLightsKey = [&](const DrawItem& item, const std::string& selective) { // A receiving mesh reads its lights' shadow maps, any other the unshadowed kinds: three keys a program on // receiveShadow, and r187 has one shadow filter, so the map type no longer enters the key. - return item.kind == MaterialKind::Basic ? basicLightsKey - : item.receiveShadow ? lightKindsKey - : unshadowedKindsKey; + return !selective.empty() ? hashBytes(selective.data(), selective.size()) + : item.kind == MaterialKind::Basic ? basicLightsKey + : item.receiveShadow ? lightKindsKey + : unshadowedKindsKey; }; // The pipeline target inputs not already in the VertexVariant: side, blending, depth state and // the vertex layout. Equal keys mean the same pipeline, so the cached handle can be reused. @@ -3743,16 +3770,31 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS } put(block, f, fs[kShadowNoiseFrame], std::array{shadowNoiseFrame()}); put(block, f, fs[kViewMipBias], std::array{viewMipBias()}); - put(block, f, fs[kHemisphereSky], lights.hemisphereSky); - put(block, f, fs[kHemisphereGround], lights.hemisphereGround); put(block, f, fs[kHemisphereDirection], lights.hemisphereUp); // world space: it meets normalWorld - put(block, f, fs[kAmbient], lights.ambient); + if (!program.selective) { + put(block, f, fs[kHemisphereSky], lights.hemisphereSky); + put(block, f, fs[kHemisphereGround], lights.hemisphereGround); + put(block, f, fs[kAmbient], lights.ambient); + return; + } + // A lightsNode program: only the ambient and hemisphere lights it lists (the slab starts zeroed). + const auto& ids = program.lightSelection; + const auto listed = [&](uint64_t id) { return std::find(ids.begin(), ids.end(), id) != ids.end(); }; + std::array ambient{0, 0, 0}; + for (const auto& [id, term] : lights.ambients) + if (listed(id)) for (int c = 0; c < 3; ++c) ambient[c] += term[c]; + put(block, f, fs[kAmbient], ambient); + if (lights.hemisphereId != 0 && listed(lights.hemisphereId)) { + put(block, f, fs[kHemisphereSky], lights.hemisphereSky); + put(block, f, fs[kHemisphereGround], lights.hemisphereGround); + } }; for (const auto& [depthKey, drawn] : opaque) { const DrawItem& item = *drawn; if (!item.mainPass) continue; shader::VertexVariant variant = variantOf(item); - const uint64_t lightsKey = drawLightsKey(item); + const std::string selective = selectiveLights(item); + const uint64_t lightsKey = drawLightsKey(item, selective); DrawCache* cache = item.cache; Program* built = nullptr; if (cache && cache->program && cache->kind == item.kind && cache->lightsKey == lightsKey && @@ -3760,7 +3802,8 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS built = static_cast(cache->program); } else { built = this->program(item.kind, variant, - item.kind == MaterialKind::Basic ? "" + !selective.empty() ? selective + : item.kind == MaterialKind::Basic ? "" : item.receiveShadow ? lightKinds : unshadowedKinds); if (!built) continue; diff --git a/packages/runtime-native/src/engine/renderer/renderer.h b/packages/runtime-native/src/engine/renderer/renderer.h index a159d69e9..37289f75b 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.h +++ b/packages/runtime-native/src/engine/renderer/renderer.h @@ -319,6 +319,9 @@ struct LightState { std::array hemisphereGround{0, 0, 0}; std::array hemisphereUp{0, 1, 0}; std::array ambient{0, 0, 0}; + /** Each AmbientLight's own term and the drawn HemisphereLight's id: a material's lightsNode sums only the ones it lists. */ + std::vector>> ambients; + uint64_t hemisphereId = 0; }; /** three's renderer output settings: `toneMapping`, `toneMappingExposure`, `outputColorSpace`. */ @@ -935,6 +938,10 @@ class Renderer { MaterialKind kind = MaterialKind::Standard; shader::VertexVariant variant; std::string lightKinds; + /** Built for a material lightsNode (the key's `#id,id,` tail): the ids of the lights it sums, so the frame slab + * writes only their ambient and hemisphere terms. */ + bool selective = false; + std::vector lightSelection; std::array, 16> aux; // by AuxTarget mask, built on first request uint16_t auxBuilt = 0; // bit `mask`: aux[mask] was asked for (it stays null when refused) /** What its stage modules alone decide about a draw of it (FM3): resolved once, never re-scanned. */ diff --git a/packages/runtime-native/src/engine/shader/position_node.h b/packages/runtime-native/src/engine/shader/position_node.h index 3c26d2303..859117fee 100644 --- a/packages/runtime-native/src/engine/shader/position_node.h +++ b/packages/runtime-native/src/engine/shader/position_node.h @@ -30,6 +30,9 @@ struct MaterialNodes { * attachment it draws (RenderDatabase::mrtName) before a draw item is built, so no program reads it; it stays * in the slot lists so the textures, storages and uniforms only an output reads are found. */ graph::Node mrtNode; + /** NodeMaterial.lightsNode: a `lights([...])` of the only lights the material sums (three's LightsNode). Not a + * graph: its args hold the light objects, so it stays out of graphs() and the renderer keys the program on it. */ + graph::Node lightsNode; bool operator==(const MaterialNodes&) const = default; /** The material's own slots: the draw item's `fogNode` is the scene's, so no material owns it. */ auto pointers() const { diff --git a/packages/runtime-native/src/engine/shader/standard.cpp b/packages/runtime-native/src/engine/shader/standard.cpp index d28c2071f..9fd8c3b93 100644 --- a/packages/runtime-native/src/engine/shader/standard.cpp +++ b/packages/runtime-native/src/engine/shader/standard.cpp @@ -1439,6 +1439,7 @@ static StandardPrograms buildStandardProgram(const StandardMaterial& material, b // SubstrateEvaluation.ush:881-899 (the SSS_TYPE_TWO_SIDED_WRAP branch); Opacity 1 keeps the light's own shadow. const ExprId subsurface = variant.twoSidedFoliage ? f.uniform("subsurfaceColor", Type::vec(3)) : kInvalid; for (std::size_t i = 0; i < lights.kinds.size(); ++i) { + if (lights.kinds[i] == LightLayout::kExcluded) continue; // not in the material's lightsNode const Incoming light = incoming(f, t, lights.kinds[i], i, fragmentView, positionWorld, normalWorld, variant.leafReceiver, variant.roughDiffuse); const ExprId irradiance = f.mul(t.saturate(t.dot(n, light.direction)), light.color); // PhysicalLightingModel.direct :675-678: F_Schlick on the specular interface at dotVH, which the @@ -1683,6 +1684,7 @@ StandardPrograms buildLit(bool phong, const VertexVariant& variant, const LightL const ExprId shininess = phong ? f.call("max", {f.uniform("shininess", Type::f32()), t.f(1e-4f)}) : kInvalid; ExprId directDiffuse = f.construct(Type::vec(3), {t.f(0)}), directSpecular = directDiffuse; for (std::size_t i = 0; i < lights.kinds.size(); ++i) { + if (lights.kinds[i] == LightLayout::kExcluded) continue; // not in the material's lightsNode const Incoming light = incoming(f, t, lights.kinds[i], i, fragmentView, positionWorld, normalWorld, variant.leafReceiver); const ExprId irradiance = f.mul(t.saturate(t.dot(n, light.direction)), light.color); directDiffuse = f.add(directDiffuse, f.mul(irradiance, brdfLambert)); diff --git a/packages/runtime-native/src/engine/shader/standard.h b/packages/runtime-native/src/engine/shader/standard.h index 13f60252a..bdfa3b155 100644 --- a/packages/runtime-native/src/engine/shader/standard.h +++ b/packages/runtime-native/src/engine/shader/standard.h @@ -200,6 +200,8 @@ struct VertexVariant { // '1'..'8': a directional virtual shadow with that many clip levels. It reads the depth // atlas `vsm{i}` and storage page table `vsmTable{i}` in both standard and basic lit programs. struct LightLayout { + /** A light the material's lightsNode leaves out: its slot keeps its index, the program reads nothing of it. */ + static constexpr char kExcluded = '-'; std::string kinds = "d"; /** Any upper-case kind: that light's shadow map is read (`light{i}Shadow*`, texture `shadow{i}`). */ [[nodiscard]] bool shadowed() const { diff --git a/packages/runtime-native/tests/native-engine/abi_test.cpp b/packages/runtime-native/tests/native-engine/abi_test.cpp index 0021d196a..428fbe375 100644 --- a/packages/runtime-native/tests/native-engine/abi_test.cpp +++ b/packages/runtime-native/tests/native-engine/abi_test.cpp @@ -2773,6 +2773,15 @@ void starter_post_bindings() { const auto shadowMatrix = call("lightShadowMatrix", {TslArg::objectOf("DirectionalLight", sun)}); if (!shadowMatrix) return; CHECK(shadowMatrix->name == "authoredLightShadowMatrix" + std::to_string(sun->id())); + // three's lights([...]) for NodeMaterial.lightsNode: one engine object per light, kept in order; a non-light is refused. + const auto cabin = std::make_shared(); + const auto chosen = call("lights", {TslArg::objectOf("PointLight", cabin), TslArg::objectOf("DirectionalLight", sun)}); + if (!chosen) return; + CHECK(chosen->kind == g::Kind::Struct && chosen->name == "lights" && chosen->args.size() == 2); + CHECK(chosen->args[0]->object == cabin && chosen->args[1]->object == sun); + bool refusedLights = false; + try { tn::abi::tslCall("lights", nullptr, {TslArg::of(g::float_(1))}, serial); } catch (const std::exception&) { refusedLights = true; } + CHECK(refusedLights); { const auto buffer = g::storage("rejection", tn::engine::shader::Type::u32()); auto element = std::make_shared(*buffer.element(g::uint_(0))); diff --git a/packages/runtime-native/tests/native-engine/material_test.cpp b/packages/runtime-native/tests/native-engine/material_test.cpp index 33b2bbc76..ec5e92ef3 100644 --- a/packages/runtime-native/tests/native-engine/material_test.cpp +++ b/packages/runtime-native/tests/native-engine/material_test.cpp @@ -533,6 +533,21 @@ void builds() { CHECK(programs.diagnostics.empty()); CHECK(WgslEmitter::emit(programs.vertex, 0).ok()); CHECK(WgslEmitter::emit(programs.fragment, 1).ok()); + // A light a material's lightsNode leaves out keeps its index but the program declares and reads nothing of it. + for (bool physical : {false, true}) { + const auto chosen = physical ? buildPhysical(StandardMaterial{}, VertexVariant{}, LightLayout{"D-p"}) + : buildStandard(StandardMaterial{}, VertexVariant{}, LightLayout{"D-p"}); + CHECK(chosen.diagnostics.empty()); + const auto fragment = WgslEmitter::emit(chosen.fragment, 1); + CHECK(fragment.ok()); + CHECK(fragment.code.find("light0Color") != std::string::npos && fragment.code.find("light2Color") != std::string::npos); + CHECK(fragment.code.find("light1") == std::string::npos); + } + for (bool phong : {false, true}) { + const auto chosen = phong ? buildPhong(VertexVariant{}, LightLayout{"-d"}) : buildLambert(VertexVariant{}, LightLayout{"-d"}); + const auto fragment = WgslEmitter::emit(chosen.fragment, 1); + CHECK(fragment.ok() && fragment.code.find("light1Color") != std::string::npos && fragment.code.find("light0") == std::string::npos); + } // The environment variant samples the PMREM as a cube (r187 PMREMNode.setup), at // roughnessToMip = maxLod * r * (2 - r), and keeps its derivatives before any branch. VertexVariant environment; diff --git a/packages/runtime-native/tests/native-engine/v8_adapter_test.cpp b/packages/runtime-native/tests/native-engine/v8_adapter_test.cpp index 689bee882..cc3a160cb 100644 --- a/packages/runtime-native/tests/native-engine/v8_adapter_test.cpp +++ b/packages/runtime-native/tests/native-engine/v8_adapter_test.cpp @@ -567,7 +567,7 @@ void catalogCoverage() { for (const auto& [name, want] : numbers) CHECK(tableConstants.count(name) == 1); for (const auto& [name, want] : strings) CHECK(tableConstants.count(name) == 1); // Supported TSL functions live on the TSL module, rather than the class/constant globals. - const std::set tslFunctions = {"atomicAdd", "atomicStore", "lightShadowMatrix", "rtt", "storageTexture", + const std::set tslFunctions = {"atomicAdd", "atomicStore", "lightShadowMatrix", "lights", "rtt", "storageTexture", "textureStore", "uvec2"}; // TSL nodes the catalog publishes as functions that the engine installs as node objects. const std::set tslNodes = {"modelWorldMatrix"}; diff --git a/packages/three-native/__tests__/browser-tsl.spec.ts b/packages/three-native/__tests__/browser-tsl.spec.ts index e1e0466a0..f04ec9bdc 100644 --- a/packages/three-native/__tests__/browser-tsl.spec.ts +++ b/packages/three-native/__tests__/browser-tsl.spec.ts @@ -408,3 +408,14 @@ describe("MaterialNode accessor census", () => { expect(bridged.sort()).toEqual([...MAP_AWARE].sort()); }); }); + +// three's `lights([...])` (a LightsNode for NodeMaterial.lightsNode) takes one array: each light must cross as its own +// engine object, so the engine can key the material's program on exactly those lights. +it("lights([...]) spreads its list into one engine object argument per light", () => { + const { runtime, tsl } = setup(); + const { lights } = tsl as unknown as { lights(list?: readonly unknown[]): INode }; + expect(lights([])[TSL_NODE]).toBeGreaterThan(0); + expect(runtime.call).toHaveBeenLastCalledWith("lights", null, []); + // A plain object is not an engine light: refused by its position in the list, not as an array. + expect(() => lights([{}])).toThrow("TN_TSL lights: argument 0 is not"); +}); diff --git a/packages/three-native/api/catalog.json b/packages/three-native/api/catalog.json index 0fb071113..5720e71ed 100644 --- a/packages/three-native/api/catalog.json +++ b/packages/three-native/api/catalog.json @@ -11774,6 +11774,15 @@ "kind": "supported" } }, + { + "name": "lightsNode", + "type": "Node | null", + "mutable": true, + "lifetime": "owned", + "status": { + "kind": "supported" + } + }, { "name": "metalnessNode", "type": "Node | null", @@ -13111,6 +13120,15 @@ "kind": "supported" } }, + { + "name": "lightsNode", + "type": "Node | null", + "mutable": true, + "lifetime": "owned", + "status": { + "kind": "supported" + } + }, { "name": "metalness", "type": "number", @@ -14009,6 +14027,15 @@ "kind": "supported" } }, + { + "name": "lightsNode", + "type": "Node | null", + "mutable": true, + "lifetime": "owned", + "status": { + "kind": "supported" + } + }, { "name": "name", "type": "string", @@ -20973,6 +21000,15 @@ "kind": "supported" } }, + { + "name": "lightsNode", + "type": "Node | null", + "mutable": true, + "lifetime": "owned", + "status": { + "kind": "supported" + } + }, { "name": "metalnessNode", "type": "Node | null", @@ -26819,6 +26855,22 @@ ], "returns": "Node" }, + { + "name": "lights", + "kind": "function", + "source": "three/tsl", + "status": { + "kind": "supported" + }, + "parameters": [ + { + "name": "lights", + "type": "Light[]", + "optional": false + } + ], + "returns": "Node" + }, { "name": "linearDepth", "kind": "function", diff --git a/packages/three-native/api/native-registry.json b/packages/three-native/api/native-registry.json index 4f30751ba..37dd505fb 100644 --- a/packages/three-native/api/native-registry.json +++ b/packages/three-native/api/native-registry.json @@ -4691,6 +4691,7 @@ "fragmentNode", "id", "irradianceNode", + "lightsNode", "metalnessNode", "mrtNode", "name", @@ -4738,6 +4739,7 @@ "forceSinglePass", "fragmentNode", "irradianceNode", + "lightsNode", "map", "metalnessNode", "mrtNode", @@ -5291,6 +5293,7 @@ "fragmentNode", "id", "irradianceNode", + "lightsNode", "metalness", "metalnessNode", "mrtNode", @@ -5355,6 +5358,7 @@ "forceSinglePass", "fragmentNode", "irradianceNode", + "lightsNode", "map", "metalness", "metalnessMap", @@ -5429,6 +5433,7 @@ "fragmentNode", "id", "irradianceNode", + "lightsNode", "mrtNode", "name", "normalNode", @@ -5469,6 +5474,7 @@ "forceSinglePass", "fragmentNode", "irradianceNode", + "lightsNode", "mrtNode", "name", "needsUpdate", @@ -8188,6 +8194,7 @@ "fragmentNode", "id", "irradianceNode", + "lightsNode", "metalnessNode", "mrtNode", "name", @@ -8239,6 +8246,7 @@ "forceSinglePass", "fragmentNode", "irradianceNode", + "lightsNode", "map", "metalnessNode", "mrtNode", diff --git a/packages/three-native/generated/three.d.ts b/packages/three-native/generated/three.d.ts index f9c21febb..2d8e8644b 100644 --- a/packages/three-native/generated/three.d.ts +++ b/packages/three-native/generated/three.d.ts @@ -1507,6 +1507,7 @@ depthTest: boolean; depthWrite: boolean; emissiveNode: Node | null; readonly id: number; +lightsNode: Node | null; metalnessNode: Node | null; name: string; normalNode: Node | null; @@ -1656,6 +1657,7 @@ emissiveIntensity: number; emissiveNode: Node | null; envMapIntensity: number; readonly id: number; +lightsNode: Node | null; metalness: number; metalnessNode: Node | null; name: string; @@ -1803,6 +1805,7 @@ fog: boolean; forceSinglePass: boolean; fragmentNode: Node | null; readonly id: number; +lightsNode: Node | null; name: string; normalNode: Node | null; opacity: number; @@ -2679,6 +2682,7 @@ depthTest: boolean; depthWrite: boolean; emissiveNode: Node | null; readonly id: number; +lightsNode: Node | null; metalnessNode: Node | null; name: string; normalNode: Node | null; @@ -3351,6 +3355,9 @@ export declare const ZeroStencilOp: 0; /** Catalog supported: three/tsl/lightShadowMatrix. */ export declare function lightShadowMatrix(light: Light): Node; +/** Catalog supported: three/tsl/lights. */ +export declare function lights(lights: Light[]): Node; + /** Catalog supported: three/tsl/rtt. */ export declare function rtt(node: Node, width?: number | null, height?: number | null, options?: Record): Node; diff --git a/packages/three-native/src/browser-tsl.ts b/packages/three-native/src/browser-tsl.ts index d7cb4531c..68448f3db 100644 --- a/packages/three-native/src/browser-tsl.ts +++ b/packages/three-native/src/browser-tsl.ts @@ -148,6 +148,7 @@ const FUNCTIONS = [ "struct", "wgslFunction", "lightShadowMatrix", + "lights", "luminance", "viewZToOrthographicDepth", "viewZToPerspectiveDepth", @@ -400,6 +401,7 @@ function takesEngineObject(name: string, index: number): boolean { name === "storageTexture" || name === "textureStore" || name === "lightShadowMatrix")) || + name === "lights" || (index < 2 && (name === "reflector" || name === "__rtt")) ); } @@ -583,6 +585,8 @@ export function defineTsl( exports.NodeUpdateType = NodeUpdateType; for (const name of FUNCTIONS) exports[name] = (...args: unknown[]) => wrap(call(name, null, args)); + // three's lights([...]) takes one array; each light crosses as its own engine object. + exports.lights = (list: readonly unknown[] = []) => wrap(call("lights", null, [...list])); exports.nodeObject = (value: unknown) => isTslNode(value) ? value : wrap(call("nodeObject", null, [value])); exports.texture = (initial: unknown, ...args: unknown[]) => {