Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions docs/verification/native-coverage-2026-08-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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% |
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-native/conformance/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
});
}
4 changes: 2 additions & 2 deletions packages/runtime-native/include/threenative/abi/tn_abi.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-native/src/adapters/v8/tsl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -692,7 +692,7 @@ void Tsl::install(v8::Local<v8::Context> context, v8::Local<v8::Object> 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())
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime-native/src/engine/abi/bindings_material.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ void registerMeshMaterial(ClassBinding& b, MaterialType type, bool node = false,
as<Material>(self)->needsUpdate();
};
}
b.getters["lightsNode"] = [](void* self) { return Value::shaderNode(as<Material>(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<Material>(self)->nodes.lightsNode = v.node;
as<Material>(self)->needsUpdate();
};
}

// ------------------------------------------------------------------------------- lights
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime-native/src/engine/abi/tsl_call.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<g::NodeData>();
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<g::NodeData>();
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<g::NodeData>();
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-native/src/engine/player/core-tsl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,7 @@ void RenderDatabase::project(Object3D& object, const Camera& camera, std::vector
const auto& l = static_cast<const AmbientLight&>(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<DirectionalLight&>(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
Expand Down Expand Up @@ -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()) {
Expand Down
65 changes: 54 additions & 11 deletions packages/runtime-native/src/engine/renderer/renderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -3584,12 +3590,33 @@ uint64_t Renderer::renderPrepared(std::span<const DrawItem> 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<uint64_t> ids;
for (const auto& entry : node->args)
ids.push_back(static_cast<const Light*>(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.
Expand Down Expand Up @@ -3743,24 +3770,40 @@ uint64_t Renderer::renderPrepared(std::span<const DrawItem> items, const CameraS
}
put(block, f, fs[kShadowNoiseFrame], std::array<double, 1>{shadowNoiseFrame()});
put(block, f, fs[kViewMipBias], std::array<double, 1>{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<double, 3> 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 &&
cache->variant == variant) {
built = static_cast<Program*>(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;
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime-native/src/engine/renderer/renderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ struct LightState {
std::array<double, 3> hemisphereGround{0, 0, 0};
std::array<double, 3> hemisphereUp{0, 1, 0};
std::array<double, 3> 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<std::pair<uint64_t, std::array<double, 3>>> ambients;
uint64_t hemisphereId = 0;
};

/** three's renderer output settings: `toneMapping`, `toneMappingExposure`, `outputColorSpace`. */
Expand Down Expand Up @@ -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<uint64_t> lightSelection;
std::array<std::unique_ptr<shader::StageModule>, 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. */
Expand Down
Loading
Loading