Skip to content

Sync fork develop with upstream: release 0.22.0 (#1305) - #70

Merged
miogds merged 24 commits into
developfrom
sync/upstream_develop_1005
Oct 5, 2026
Merged

miogds merged 24 commits into
developfrom
sync/upstream_develop_1005

Conversation

@miogds

@miogds miogds commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

Brings the fork's develop up to upstream develop at ffb248dd (untoldengine#1305, the release 0.22.0 commit). The fork lacked 23 upstream commits.

What it brings

Conflicts

18 files conflicted. In each, the fork's side was an earlier copy of work that upstream now holds, so all of them take upstream's version: reach IK, the component pool and the scene, the vertex stream binding, the animation system, the lock census test, the CLI's command list, three documents, the exporter, and the seven shader libraries (the merged shader sources are upstream's).

After the merge the fork's tree is upstream's plus 13 proposal documents under docs/proposals. There is no fork-only code left in develop.

Verification

Locally, on the merge commit:

  • Build, UntoldEngineTests (1,624 tests, 2 skipped; the external package test passes from a checkout named UntoldEngine), UntoldComponentKitTests (59).
  • make testexporter: 230 pass.
  • The strict-concurrency build has no warnings.
  • The editor fork's develop (aba6ce4), with the Gaussian twins package, builds against this head and passes its tests (1,071 tests, 1 skipped).
  • The render suite was not run again: the code is upstream's, which its CI covers.

After the merge

miogds and others added 24 commits October 1, 2026 17:34
…layers, physics pose, compute skinning, DQS/DDM, morph targets, pose drivers, XPBD muscles, ML deformer, motion-capture seams (untoldengine#1259)

* [Feature] Animation foundation and character deformation stack

Animation foundation: pose layers with reach IK, a physics pose seam,
external poses (motion capture, trackers) solved through the hierarchy,
foot IK stance locking, motion matching refinements, mesh and skeleton
queries and debug lines.

Character deformation: a compute pass ahead of the shadows that skins
DeformationComponent meshes (linear blend and direct delta mush) with
morph targets, pose-space drivers, an XPBD muscle simulation with a
debug overlay, an ML deformer trained on the simulation, and the asset
format (morph targets, muscle rigs, ML payloads) they load from.

* [Feature] Tools for the deformation stack: ML deformer bake and trainer, exporter morph targets and muscle rigs

The CLI bakes ML deformer training data from a muscle simulation, the
trainer script fits the payload, and the Blender exporter writes morph
targets and muscle rigs; a rigged mesh is now split by material with
Blender's own separate, so its skin and shape keys survive.

* [Chores] Documentation for pose layers, reach IK, the physics pose and the deformation stack

* [Patch] Deformation state shared across threads is safe by construction

A mesh deformation override takes its own lock in write and in the new
current(), instead of relying on every caller holding the component's.
The ML deformer's load state is read and written only through locked
accessors on the component: one caller starts the load, and a load that
finishes after a reset (another payload, the component going away) is
dropped instead of ending the newer state.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
* [Docs] Added documentation for  XR lifecycle

* [Docs] Improved real-surface API docs

* [Docs] Added scene-root scaling docs
…ntoldengine#1291)

* [Patch] Probe .untoldgs/.ply for Gaussian loads and surface dropped pack scene-payload

Extends the omitted-withExtension probing already in place for mesh/animation
loads (.untoldpack/.untold, .untoldanim/.untold) to Gaussian splats: omitting
withExtension on setEntityGaussian, setEntityGaussianAsync, and
GaussianSource.single now probes .untoldgs then .ply.

Also surfaces a gap found along the way: the Blender add-on's export
operator never told the user when a multi-model .untoldpack export dropped
their scene's lights/cameras/color-grade LUT, even though the CLI already
printed that note to the console. The add-on's report now shows it too.

Docs updated to document the new defaults and to stop showing withExtension
on calls that no longer need it, while keeping it on loadSceneAuthored and
setEntityStreamScene, which don't probe.

* [Chores] fixed formatting
…e export (untoldengine#1284)

* [Bugfix] Carry Blender's colour nodes, transparency and glass into the export

Materials in a cooked .blend scene differed from Blender in five ways:

- Colour nodes between an image texture and its socket were dropped
  (Gamma, Bright/Contrast, Hue/Saturation/Value, RGB Curves, ColorRamp;
  Invert only at full strength). RGB Curves was never even traced, as
  Blender 4+ names the node ShaderNodeRGBCurve, and a texture behind a
  ColorRamp was lost. They are now written into the staged texture as
  image adjustments, on linear values, with the formulas of Cycles'
  nodes; checked against Cycles bakes of each node (max error 0.0002).
- Every material was flagged opaque, so Alpha and glass rendered
  solid. The alpha mode is now written into the material flags: a
  constant Alpha blends at that opacity, the base colour image's own
  alpha is used as is, and an Alpha from another texture is written
  into the base colour texture's alpha channel (a white texture when
  the base colour is a constant), since the engine reads alpha there.
  Glass is approximated as a blended surface: Transmission keeps 10 %
  opacity, Transparent BSDFs mixed in lower it, a mix driven by
  Backfacing takes its front side; the fidelity report says so.
- A mesh using only a later material slot exported the first slot's
  material (11 objects in the scene that showed this: signs, an
  emissive panel and three cylinders came out as metal or glass).
- EXR textures were always skipped; with Blender at hand they are
  converted to PNG like other high-bit-depth sources.
- Lights and cameras were taken from every object in the file; they
  now follow the objects' rules (excluded collections never, hidden
  ones with --include-hidden).

* [Patch] Export node math without a texture as seen straight on

A Base Color, Roughness, Metallic or Emission input driven by node math
with no texture behind it exported white or the input's stale slider:
the two ovens of the scene (METAL.001, built on Layer Weight curves)
came out white and half rough instead of black and shiny.

evaluate_socket_facing reduces such a chain to the value it gives a
surface seen straight on: Mix (skipping the side a factor of 0 or 1
ignores), Math, RGB Curves, ColorRamp, Gamma and the other colour
nodes, Value and RGB, reroutes and node groups; Layer Weight Facing is
0 there and its Fresnel and the Fresnel node the normal-incidence
reflectance. A texture in the chain still leaves the slider value.

Checked against Cycles bakes of METAL.001's own sockets (a bake looks
along the normal, the straight-on view): base colour 0, roughness
0.2702, specular 0.214, the same values to four decimals.

* [Patch] Read a linked Transmission Weight instead of taking it as none

_unlinked_value returned None both for a missing "Transmission Weight"
socket and for a linked one, so a linked socket fell through to the
pre-4.0 "Transmission" name, which a 4.0+ node lacks, and read as 0:
glass whose transmission is driven by a texture (a frost or dirt mask)
exported fully opaque, and the fidelity report said nothing.

principled_transmission looks up the old name only when the new one is
absent. A linked socket gets the value its node chain has seen straight
on (evaluate_socket_facing); with a texture in the way the slider value
stands in, as for Roughness and Metallic, and the fidelity report says
the transmission could not be followed. Both the surface opacity and
the report use it.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
…ground space (untoldengine#1283)

* [Feature] Reach IK: a target per chain, in world, model or shoulder space

Chains shared one world target, which suits a character grabbing at something and not hands that follow tracking. setReachIKChainTargets gives every chain its own; a shoulder-space target is an offset from the chain's shoulder, so the hand keeps its place on the body without a frame of lag. The easing of the targets is a parameter.

* [Feature] Reach IK: ground-spot chain targets, for a leg holding a foot where it stands

A target in the new modelGround space is a spot whose height is ignored: the chain's end goes over it at the height the pose gives it.

* [Patch] Reach IK: the chains' targets ease on their own halflife, and the shared influence is documented

setReachIKChainTargets set the same target easing the shared target
uses, so following tracked hands exactly made a shared grab target jump.
The chains' targets have their own halflife now. Weight, halflife and
reach stay one influence per entity, set by whichever of the two calls
runs last: said on both functions and in the guide.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
Component pools were one block of MAX_ENTITIES (20,000) entries, and
neither Scene.assign nor ComponentPool.get checked the index: a scene
with more entities wrote and read past the end of the storage. A BIM
site cooked to a .untoldpack needs over 21,600 entities (10,314
placements, many of several nodes). The culling path's reduce-scan
buffers had the same fixed size and are indexed per bounding box,
which can outnumber entities (long meshes are split into segments).

- ComponentPool keeps its storage in 4,096-entry chunks added as the
  scene grows (Scene.assign reserves the entity's index first). A chunk
  is never moved or freed before deallocate, so a copy of the scene
  taken on another thread under the scene lock keeps reading through
  its own chunk list while the scene grows. An index past the reserved
  chunks reads as no component.
- The reduce-scan buffers grow with the frame's bounding-box count
  (ensureReduceScanCapacity, powers of two), and the block count
  follows the frame instead of the constant. Past what the single-
  threadgroup block scan takes (1,024 blocks of 256 boxes) the extra
  boxes are not drawn, and the log says so once.
- MAX_ENTITIES becomes INITIAL_ENTITY_CAPACITY: the size per-entity
  buffers start at, not a limit.

Co-authored-by: Javier Segura <javier@miolabs.com>
…ne#1288)

* [Performance] Write repeated models once in a .untoldpack

A .blend scene full of copies cooked every copy in full. On a BIM site
model (22,741 meshes, 289 million visible faces of which 98 % repeat:
one 1.5-million-face tree placed 175 times) the cook extracted for two
hours and wrote 21 GB before it was stopped, each tree 109 MB of
geometry plus its own copy of the same textures.

- Linked duplicates without modifiers (mesh_share_key: same mesh
  datablock and materials, not rigged) are split by material once and
  extracted once; each copy reuses the result with its own placement
  (placed_mesh_copy).
- A pack model whose content matches one already written
  (model_content_signature: geometry, materials and hierarchy, not
  names or root placement; skinned and morphing models excluded) is
  not written again: its manifest entry points at the same .untold
  with its own transform, which the pack loader already supports.
- The models of a pack share one Textures/ folder beside their
  folders (references ../Textures/...) instead of a copy each; a
  model folder's old Textures/ is removed when it is rewritten.
- untoldengine export --optimize bakes that folder once and patches
  each distinct model file; texbake --untold accepts a folder.

The same scene now cooks in 6 min 37 s to 1.9 GB (10,314 placements,
4,583 files). The building of untoldengine#1267 cooks in 57 s instead of 2 min 28
and 278 MB instead of 2.4 GB, every placement landing exactly where
the previous exporter put it.

* [Patch] Share copies of a mesh that has no material

A mesh with no material was given one named after its object
("Tree.001_material"). The material is part of a pack model's signature, so
two copies of a prop with no material, Tree.001 and Tree.174, counted as two
models and were each written in full, although only the object's name set
them apart. Linked duplicates were shared all the same, since they take the
first object's mesh; full copies were not.

Every mesh with no material now gets the same one, DEFAULT_MATERIAL_NAME.
Inside one .untold, such meshes also share a single material record.

* [Patch] Bake a pack's textures with hints from its own models

The CLI gave texbake the folder that holds the pack's Textures, and texbake
read every .untold under it for the flags of each texture (sRGB or linear,
normal map), keyed by the texture's name, the first file to name one
winning. With --assets-dir that folder can hold other exports: one whose own
"wall" is a normal map decided how the pack's "wall" color texture was
encoded.

--untold now takes the pack's manifest and reads the models it lists, each
once, with paths relative to the manifest as the engine resolves them. The
list of models stays out of the command line, which a large pack would
overflow. A folder is refused, and a manifest that cannot be read is
reported and leaves the textures to be told apart by their names.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
* [Performance] Load each .untold of a pack once

A .untoldpack can place one .untold hundreds of times (the exporter
writes a repeated model once), and every placement read the file,
decoded it and allocated its own GPU buffers and textures. The BIM site
of docs/proposals/LargeSceneRendering.md (10,314 placements of 4,583
files, a 1.85 M-triangle tree placed 175 times) took 118 s to load and
57.7 GB of GPU memory.

- The pack loader keeps an UntoldBuildCache for the duration of the
  load: the first placement of a file parses it and builds its meshes,
  placements arriving meanwhile wait for that build instead of making
  their own (single-flight, no thread blocked), and every placement
  registers its own copy of the Mesh values. Mesh is a value type whose
  buffers and textures are references, so the copies share the GPU
  memory while material edits and skins stay per entity.
  setEntityMeshAsync forwards to loadEntityMeshAsync, which takes the
  cache; nothing changes for callers outside a pack.
- Image textures (PNG, JPEG, ...) loaded for runtime materials go
  through LoadedTextureCache, by file and colour space, holding each
  only while some material uses it: models sharing an image (a pack's
  shared Textures/ folder) decode it once. Loaded textures are never
  written in place, so sharing them is safe.

The same pack now loads in 20 s with 4.8 GB of GPU memory.

* [Bugfix] Count the GPU memory that placements share once in the memory budget

Placements of one pack file share its vertex and index buffers, and models
share the images of a pack, but every entity still registered the full
size of what it holds. A model placed 500 times was in the budget 500
times. The budget then reports pressure that is not there: geometry
streaming holds back its loads, and StreamingRegionManager destroys the
least recently used entities to make room.

MemoryBudgetManager.registerMesh(entityId:meshes:) now registers the
buffers and textures the entity holds, and the totals count each of them
once, while any registered entity holds it. The buffers of a model leave
the totals with its last placement. The ledger knows an allocation by
identity through a weak reference, so it never keeps GPU memory alive and
an address handed out again is not mistaken for the allocation that was
there before.

getMemorySize(for:) and the eviction candidates report what evicting an
entity would free: a placement that shares its buffers frees nothing until
it is the last one, and getEvictionCandidatesToTarget() counts a shared
buffer with the last of its holders. Sizes registered as numbers, as the
streaming paths do, count as the entity's own, as before; a texture size
update lets go of the textures the entity was registered with.

A buffer or a texture that one entity uses more than once (an index buffer
that submeshes share, an image in two slots of a material) is counted once
as well; it used to be counted for each use.

* [Patch] Decode an image once when several models ask for it at the same time

LoadedTextureCache looked a texture up and stored it after decoding, with
nothing in between. A pack loads eight models at a time, and models that
share an image could all miss the cache before the first had decoded it,
and each decode and upload it.

The cache is now single-flight, the way NativeTextureLoader is: a thread
that asks for an image another one is decoding waits on a condition and
takes its texture. Materials are built synchronously, so the wait is a
blocking one, and the lock is not held while an image is decoded. A load
that fails is not remembered.

* [Patch] Try a pack file a second time before its placements fall back

The build cache remembers a failed build, so that a broken file placed
hundreds of times is read once. That also turned one passing read error
into a fallback mesh for every placement of the file, where each
placement used to load on its own.

The caller that builds a file now tries a second time before the failure
is remembered. A passing error costs one more read; a file that is really
broken is read twice and no more.

* [Refactor] Keep the pack build cache's states inside an unfair lock and say when a load is shared

The cache kept the finished builds and the waiting callers in two tables
behind an NSLock, and told "not built yet" from "built, and it failed"
with a nested optional. It now has one table of states (being built, with
its waiting callers, or finished) and small enums for what a caller has to
do and what became of a caller that went to wait.

The table lives inside an OSAllocatedUnfairLock, so it can only be read or
changed while the lock is held, and the class is Sendable without
@unchecked. The lock is held for a few instructions around the table,
never while a file is built or a waiting caller is resumed; a test builds
two files that each wait for the other to have begun.

The test that decides whether a load uses a shared build now says what it
depends on: a shared build is the whole file built at once, so a load of
one named node or a streamed one is not shared even when the caller
passes the cache, and this is the place to change when a pack needs
either.

* [Patch] Let the build cache's private enums be Sendable by inference

SwiftFormat 0.60.1, which CI runs, rejects an explicit Sendable on a
non-public enum (redundantSendable). The three enums of UntoldBuildCache
are Sendable without saying so, and the strict-concurrency build still
has no warning.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
…ion, reflections (untoldengine#1292)

* [Bugfix] Decode normal maps without tilting them, and apply the normal strength

The model and transparency shaders normalized a normal map's stored color
before remapping it from [0, 1] to [-1, 1]. That shortens the color first,
so every decoded normal came out tilted: a flat texel, (0.5, 0.5, 1), gave
(-0.18, -0.18, 0.63), about 22 degrees off the surface. A neutral normal
map lit a face a third brighter than no normal map at all. Maps packed by
texbake (X and Y only) were not affected.

The remap now comes first and the normalization after.

The material's normal strength was also read from .untold files and then
dropped. Material carries it now (normalScale) and the shaders mix the
mapped normal with the surface's own by it, as Blender's Normal Map node
does. Static batching keeps materials of different strengths apart.

* [Bugfix] Draw a black base color as black

The model and transparency shaders took a base color factor of zero for
"not set" and replaced it with white, whether the material had a base color
texture or not. A material that is simply black (a black oven, a screen
that is off, black rubber) was drawn white.

Zero now means "untinted" only when there is a texture to show; without
one it is the color it says.

* [Bugfix] Draw emissive textures, and the glow of blended materials

A material's emissive texture was loaded and never bound: a lit sign showed
the flat color of its emissive factor in place of what is painted on it. The
model pass now draws the factor times the texture, as glTF defines it, and the
transparency pass, which drew no emission at all, does the same.

A material read from a USD file carries the texture without a factor, so its
factor is white when it has one.

The emissive color and texture are now part of what tells two materials apart
for batching: two lamps alike in all but their glow could share a batch.

* [Bugfix] Blur the environment a rough surface reflects

The environment map is filtered once per roughness when it is loaded: one mip
level per roughness, from a mirror image to the widest blur. The sampler that
reads it had no mip filter, so the level asked for was ignored and every
surface reflected level 0: a rough metal showed the room as sharply as a
mirror.

The environment now has its own sampler, with a mip filter, that wraps around
the horizon and stops at the poles. The lookup table keeps the clamped sampler
that fixed the white spot.

* [Bugfix] Read the reflection table by the roughness it was written for

The table that tells how much of the environment a surface reflects was
written with its rows running from a roughness of 1 down to 0, and read with
the roughness as the row: a polished surface took the share of a rough one
and the reverse. A polished metal reflected about a third of the environment,
so its reflections came out dark, and a rough one all of it.

The rows now run from 0 to 1, the way specularIBL reads them, in the table of
the environment map and in the one of the XR environment probe.

The test lights a sphere with an environment that is equally bright all
around: a polished white metal then gives back as much light as a matte white
surface, where it gave back 36 % of it.

* [Bugfix] Give a rough metal back the light it bounces more than once

The reflection table counts one bounce of light on the surface. A rough
surface bounces part of the light between its own bumps before it lets it go,
and the table takes that part for lost: the roughest white metal gave back a
third of the light around it, and a satin one 89 %.

The environment reflection now adds the later bounces back, each tinted again
by the surface (Kulla and Conty's approximation, in the form Filament uses).
In an environment equally bright all around, a white metal of any roughness
gives back all the light, and a colored one comes out deeper in color, not
brighter than its color allows.

With the table read upside down a rough metal took the share of a polished
one, so this keeps rough metals as bright as they have been.

* [Bugfix] Reflect the environment on surfaces that are not metal

The environment light was the diffuse part for a non-metal and the reflection
for a metal, blended by the metallic value. A non-metal reflected nothing:
polished plastic, glass and lacquer came out matte, and a black one came out
with no light on it at all.

Every surface now reflects the environment: 4 % of it head on for a non-metal,
more when it is seen at a slant. What it reflects it does not scatter, so the
diffuse part gives up that share and a white surface still gives back all the
light around it and no more, whatever its finish. A metal scatters nothing, as
before.

The reflection takes the reflectance head on (F0), as the lookup table
expects, in place of the Fresnel value at the angle of view, which counted the
slant twice. The share a surface reflects is now one function,
environmentReflectance, that both the reflection and the diffuse part read.

The angle of view counts whichever side of the surface is seen. Both faces of
a surface are drawn, and one seen from behind was taken for one seen edge on,
where a smooth surface reflects nearly everything: the far wall of a glass
object would have shone through its front.

* [Bugfix] Read a grayscale color texture as sRGB

A color texture whose red, green and blue are the same is stored with one
channel. The engine draws it into an RGBA image before it loads it, so that
the shaders do not show it red, and loads that image with
MTKTextureLoader.newTexture(cgImage:), which ignores the .SRGB option: the
texture came back as rgba8Unorm and its sRGB values were read as linear ones.
A mid gray came out 2.3 times as bright as the same gray stored with three
channels, so a gray metal whose base color is such a texture reflected twice
the light it should.

The expanded texture is now handed back as an sRGB view when it holds colors,
with its smaller mip levels built again through the view. Textures of values
(roughness, metallic, height) are read as stored, as before.

The material loader and the texture streaming system each had a copy of the
expansion: both now call loadGrayscaleTextureAsRGBA.

* [Chores] Rebuild the shader libraries and the reference images

The shader libraries for every platform, built with buildkernels.sh from the
shaders of the commits before this one.

Twelve reference images change with the shading: the reflection table itself
(BRDFIBL), and the scenes that show a metal sphere under the environment
(LightPassColor, DisplacementMap, TransparencyTarget and the post-effect
targets Bloom, ChromaticAberration, ColorGrading, DepthOfField, FXAA, MSAA,
SMAA, Vignette). Written by the reference generators of RendererTests and
PostFXTests.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
untoldengine#1294)

* [Docs] Actors and locks: benchmark, lock census of the frame and rules

Research note docs/proposals/ActorsAndLocks.md with its two tools:

- Examples/ConcurrencyBench: standalone package that measures a Swift
  actor against NSLock, NSRecursiveLock, OSAllocatedUnfairLock, Mutex
  and a serial queue (uncontended, contended, latency seen by a
  synchronous thread with the thread pool idle and saturated, a pass
  over 10,000 entities).
- LockCensusTests: opt-in render test (UNTOLD_LOCK_CENSUS=1) that counts
  every NSLock / NSRecursiveLock acquisition in renderer.draw and
  attributes it to the engine function that made it.

No engine code changes.

* [Patch] Lock census: check its own attribution, reject non-positive counts, note the permanent hook

Review fixes for untoldengine#1294.

- The caller is the frame right after the owner. The `!= owner` filter skipped
  nothing (0 of 4.0 million acquisitions in a debug build at 1,000 cubes, 0 of
  10.5 million in a release build at 4,000 cubes), so the published attribution
  stands; it could only ever have hidden a recursive caller.
- Before it reports, the test takes a known lock 100 times from a known
  function and asserts the census attributes all of them to that function and
  to its caller. A toolchain that names the hook's thunk differently now fails
  the test instead of skewing the report. Passes in debug and release; fails
  with "0 is not equal to 100" when the thunk filter is wrong.
- intEnv falls back to the default for zero and negative values, like the
  helper of the other benchmarks. A frame count of 0 divided by zero before.
- install() says the hook stays for the rest of the process, on purpose.
- The note pointed to section 6 for the census: the commands are in section 7,
  the census in section 4.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
…ldengine#1297)

A scene with tens of thousands of small parts (a building model with every
clip and bolt) shows most of them at a pixel or less from a distance, and
each still costs a draw, plus a shadow draw near a light. In an overview of
a BIM site with 21,576 render entities, half of the 17,783 visible ones are
under two pixels tall.

The loop that gathers the AABBs for the frustum cull now leaves out every
entity whose bounding sphere is under a set number of pixels tall, and the
directional, spot and point shadow caster lists leave out the same entities.
The size comes from the sphere's radius, its distance to the camera, the
projection in use and the height of the viewport, so it follows the field
of view and the resolution. Nothing is culled by size under an orthographic
projection, and an entity whose bounds have no size is never culled.

setRendering(.smallObjectCulling(pixels:)) sets the size. The default is one
pixel, and 0 draws everything as before. getSmallObjectCullingPixels() reads
it.

When nothing is left to test, the cull now publishes an empty visible set.
It used to return early, and the frame kept drawing the previous set.

Co-authored-by: Javier Segura <javier@miolabs.com>
…hanges (untoldengine#1295)

A pull request that only touches docs/, mkdocs.yml or a Markdown file at
the repository root no longer runs "CI Build and Test" (about 45 minutes,
most of it the renderer suite) or the SwiftFormat lint. The MkDocs build
keeps its own workflow. Markdown files elsewhere, such as template
resources under Sources, still trigger everything.

Co-authored-by: Javier Segura <javier@miolabs.com>
…ngine#1298)

Every draw asked MetalKit for what it binds: the vertex buffers of the mesh
(MTKMesh.vertexBuffers builds a new array each time it is read, and each of
its elements answers through Objective-C) and the primitive type, index
count, index type, index buffer and offset of the submesh. That is about a
dozen Objective-C calls and an array per draw, in every pass that draws
meshes.

A Mesh now keeps the Metal buffers of its MetalKit mesh, and a SubMesh the
index data of its MetalKit submesh, read when they are made. They cannot
go out of date: metalKitMesh and metalKitSubmesh are constants. The model,
shadow, transparency, wireframe and occluder shell passes, and the model
surface of render extensions, draw from them.

10,000 cubes (11,896 draws with their shadows; release build, M4 Max):
66 ms to 61 ms of CPU time per frame, 0.4 microseconds per draw.

Co-authored-by: Javier Segura <javier@miolabs.com>
…ok (untoldengine#1293)

* [Bugfix] Export glass as opaque as what its colour, metal and roughness hide

A transmissive Principled BSDF was exported as a blended surface at 10 %
opacity whatever else it was. In Blender three things take from what is seen
through it:

- The base colour tints the light that crosses it. Black tempered glass lets
  none through and looks like a black mirror; it came out as clear as a
  window, showing the inside of the oven behind it.
- Blender lays the metal over the glass, so a metal lets nothing through
  whatever its transmission says.
- A rough surface scatters what crosses it: frosted glass shows nothing of
  what is behind it.

An imported car whose materials all carry a Transmission of 1 (paint, chrome,
tyres, plastics: 20 materials on 4,300 objects of one scene) came out all but
invisible.

The opacity now follows what is seen through the surface: its transmission,
times what is not metal, times the brightness of its base colour, times what
is not rough. Clear polished glass keeps 10 %; black glass, metals and fully
rough surfaces are exported opaque (and so keep their reflections at full
strength); the rest sits in between. A base colour, a metallic value or a
roughness that comes from a texture counts as clear, as no metal and as
polished. The material report says when glass is exported opaque.

* [Bugfix] Keep the pack when --output names it

A scene with several models is written as a .untoldpack next to where the
single .untold would be, and whatever --output names is then removed as a
stale single-file export. Given the name of the pack itself, the exporter
wrote the manifest, removed it and reported success: the models were left
without the file that lists them.

An output path that ends in .untoldpack now stands for the .untold of the
same name, in the command line and in the two writers the Blender add-on
calls.

* [Bugfix] Leave out a height too deep to be a parallax depth

The exporter carries a Displacement node's Scale, or a Bump node's Distance,
over as the depth of the engine's parallax occlusion mapping. Blender leaves
both at 1, a metre, and with its default "Bump Only" displacement draws the
shading of a bump from them. The engine's depth is a share of the texture's
width: at 1 the parallax smears the texture across the surface, and the
leather of a chair and the leaves of a plant came out as streaks.

A height whose Scale or Distance is above 0.2 is now left out of the export:
the surface keeps its normal map, and the material fidelity report says that
the relief is not exported. Smaller values export as before.

* [Bugfix] Report a height as too deep only when the export read it

The material fidelity report looked for Displacement and Bump nodes on its
own, apart from the lookup the export uses, and asked less of them: a linked
Height input was enough. The two disagreed:

- A Bump node at Blender's default Distance, beside a Displacement whose
  height is exported, was reported as a height left out for its depth. The
  export reads a Bump node only when the Displacement gives it no height.
- A height that comes from a procedural texture, which is not exported
  whatever its depth, was reported as too deep. A building and a site had
  one such material each.

material_height is now the one lookup: it returns the height a material is
exported with, and the heights read on the way to it that are left out as
too deep. extract_material exports the first and analyze_material reports
the second. The engine's default depth has a name, DEFAULT_HEIGHT_SCALE.

None of the 458 materials of those two scenes exports differently; their
reports lose the two lines that gave the wrong reason.

* [Bugfix] Add-on: bake and patch the files the export wrote

With "Bake textures" on, the add-on went on to patch the path it was asked
to write, not the files that were written:

- A scene with one model is written as a single .untold even when the output
  names a .untoldpack. The bake then read scene.untoldpack, which is not
  there, and the export failed after it had written scene.untold.
- The models of a pack share one Textures folder beside their own folders.
  The bake looked for a Textures folder inside each model's folder, found
  none and reported that there were no textures.

The bake now takes the written files from the export's result: it bakes the
Textures folder beside them once, with the texture hints of those files, and
patches each of them, as `untoldengine export --optimize` does. The
operator's message names the file that was written.

* [Patch] Follow a transmission once in the material report

The report asks for a surface's transmission and then for what is seen
through it, which starts by following the same socket again.
principled_transmittance now takes the answer its caller already has.

* [Bugfix] Report the relief of a surface that is not a Principled BSDF

Only a material with a Principled BSDF is exported with a height. The one
lookup of the last change took that into the report as well, and there it
went too far: a Displacement on a Diffuse, a Glass or an Emission surface
was no longer reported at all, where before it was reported as too deep
when its Scale was above 0.2.

That earlier line gave the depth as the reason, and a smaller Scale would
not have brought the relief back. Below 0.2 the report said nothing, and an
Emission surface with a Displacement counted as supported.

material_height now returns each height it leaves out with why: its depth,
or its surface. A Displacement on a surface that is not a Principled BSDF
is reported at any depth, as not exported because of that surface.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
…ntoldengine#1299)

* [Chores] Add meshoptimizer's simplifier as a C++ target

CMeshOptimizer holds the files of meshoptimizer 1.3 (MIT) that the asset
cook uses, unmodified: the simplifier, the index generator, the vertex
cache and vertex fetch optimizers and the allocator they share. It is
called from Swift through its C API; no C++ interoperability is needed.

* [Performance] Automatic LOD chains for the models of a .untoldpack

A scene exported from Blender has no levels of detail, and a pack can place
a very dense model hundreds of times. The cook now writes simplified levels
of every model that is detailed enough, and the pack loader registers them
on the entities of each placement.

Cook (UntoldMeshLODCooker, `untoldengine bake-lods`, last step of
`untoldengine export` for a pack):
- levels at 50 %, 15 % and 3 % of the triangles by default, for models of
  2,000 triangles or more, written next to the model as <name>_LOD<n>.untold
- a level may deviate by the size of one of its triangles; vertices that two
  meshes of a model share stay in place; texture seams are kept on textured
  meshes unless they hold a level well above its target
- the small detached pieces of an aggregate (leaves, twigs) are thinned
  evenly and the survivors enlarged, so a canopy keeps its area
- the manifest records each chain under "lodChains", with a screen size per
  level

The cook is a library of its own, UntoldEngineMeshCook, which the CLI links.
The UntoldEngine module does not import the simplifier: code that is compiled
against a built engine module without SwiftPM, as the editor compiles the
components of a project, would otherwise need the module map of the C++
library and fail with "missing required module 'CMeshOptimizer'".

Loader:
- each level is built once per pack load and shared by the placements
- a level's screen size becomes a switch distance per placement, from the
  model's bounds, the placement's scale and the field of view
- levels are drawn with the model's materials, and the materials of an
  entity follow it from level to level

LOD system:
- the hysteresis takes at most a tenth of a level's switch distance, so a
  level that switches closer than the hysteresis can be returned to
- the configuration is read once per update, not per entity

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
…ne#1300)

A component object was never released. Removing a component, removing all
of an entity's components and destroying an entity only cleared the
entity's mask, and assigning a component wrote the new object over the
old one without releasing it. Every destroyed entity left its component
objects behind (416 bytes for an entity with a mesh, and what they hold),
and every component that was added and removed left one more.

Releasing on the spot is not safe: the scene is read through copies taken
under its lock and used after it, so a reader may be about to retain the
object in a slot whose component has just been removed.

A component that leaves its slot now moves to its pool's quarantine and
is released when no other copy of the pool exists, that is, when nothing
can read it any more. The copies are counted by the reference count of
the pool's storage, which a copy already retains, so reading costs what
it did. The release runs after entities are finalized and at the start
of every frame, outside the scene lock.

A copy of the scene that was changed on its own, or an old copy put back
as the scene, no longer knows which slots hold a component: its pools
keep their components, as before.

Co-authored-by: Javier Segura <javier@miolabs.com>
…ngine#1301)

createEntity() gives an entity its LocalTransform, WorldTransform and Scenegraph
components. Six load paths registered the three again on the entity they had just
created: the models of a .untoldpack, the tile stubs and the shared bucket of a tiled
scene, the mesh root, the HLOD and the LOD entities of a tile, createStreamingEntity
and the entities of a deserialized scene.

Each of those registrations cost twice:

- Scene.assign makes a new object every time, so the three components were made,
  initialized and replaced: 320 bytes per entity that the scene did not release.
- registerTransformComponent asks for a scene graph traversal on the next frame. The
  traversal reads every entity of the scene once per level (8.6 ms at 24,163
  entities), and while a pack or a tiled scene streams in nearly every frame followed
  a registration. Nothing needed it: the loaders place entities with setParent,
  translateTo, scaleTo and rotateTo, which update the world matrix at once, and an
  entity made with createEntity() alone, as every node of a loaded asset is, never
  asked for one.

The six paths now keep what createEntity() made. Release build, M4 Max, medians of
alternating runs:

- createStreamingEntity + destroy: 4.28 -> 3.54 us per entity
- heap kept per created entity: 832 -> 512 bytes
- a pack of 10,314 models (24,163 entities) loaded while frames run at 120 Hz:
  17.8 -> 13.1 s; frames that ran a traversal: 861 of 1,020 -> none (5.0 s)
- the same load with no frames running: 13.4 s both ways

The entities stay marked dirty until something else asks for a traversal (a camera
move), so the first one after a load does the work once: 16.8 ms instead of 8.7 ms
on that pack.

Left as they are: the light functions, the root of a tiled scene and
Node.registerTransformComponent(), which register on an entity the caller made.

Co-authored-by: Javier Segura <javier@miolabs.com>
… slot (untoldengine#1302)

The scene is read without its lock: `scene` hands out a copy of the Scene
under the lock, and the copy is read after it. Scene.assign wrote a new
component into its slot with a plain store and a reader read the slot with
a plain load, so when a reader's copy said the slot was in use, nothing
ordered the read after the new object's initialization. A slot in use gets
a new component when an entity is given a component it already has (the
light functions register the transform components again), when a component
is removed and given again (a mesh that is replaced), and when the index of
a destroyed entity is taken by a new one.

Thread Sanitizer reports it in the three cases. It is not only a formal
race: the stores of one core reach another out of order on Apple silicon.
Two plain stores, a field and then the pointer to it, read from a second
thread that follows the pointer, showed the pointer without the field 136
times in 20 million tries on an M4 Max, and never once the pointer is
stored with a release store or a compare and exchange.

A component that is an object now goes into its slot with an atomic compare
and exchange, which comes after everything its initializer wrote, and is
read with an acquire load. A reader gets the component the slot had or the
new one, and either one whole. What assign does is unchanged: an entity
given a component again still gets a new one, and the one it had goes to
the quarantine first.

Whether a slot holds one reference to an object is kept in the chunk list's
header, beside the quarantine, so the pool stays one reference.

Reads cost what they did, within a nanosecond: the acquire load is one
instruction in place of another. Scene.get asks the pool for the slot and
lets the pool go before it reads, because a pool kept across the atomic
load is retained and released on every call (4 ns). The scene the caller
holds keeps the pool's storage, so what the quarantine holds back stays
held back.

A component that is a value is not one reference: it is written and read
in place, as before.

Co-authored-by: Javier Segura <javier@miolabs.com>
… light (untoldengine#1303)

createDirLight, createPointLight, createSpotLight and createAreaLight registered
the transform and scene graph components of the entity they were given. Registering
replaces a component with a new one, so an entity that was already placed went back
to the origin at scale one and dropped out of its hierarchy on its own side only:
its parent still listed it and its children still pointed to it.

The registrations date from before createEntity() attached these components itself.
The engine's loaders make the light on a fresh entity and place it afterwards, so
they never saw it. A scene builder light node given an existing entity did, and so
did the editor's Add Component.

The four functions now register the components only for an entity that has none, as
setEntityMeshDirect and the asset nodes do.

The default orientation of a light (a sun straight overhead, the other lights
pointing down) is applied to an entity that has not been rotated, which is what its
comment describes. An entity that was rotated before keeps its rotation, and the
light emits along its local -Z.

A light made on an entity from createEntity() no longer asks for a scene graph
traversal on the next frame. None is needed: rotateTo updates the world matrix of
the entity and of its children at once.

Co-authored-by: Javier Segura <javier@miolabs.com>
…gine#1304)

setEntityStreamScene registered the transform and scene graph components of the
root entity it was given. Registering replaces a component with a new one, so a
root that already had a parent or children dropped out of its hierarchy on its own
side only: its parent still listed it and its earlier children still pointed to it,
while the root reported no parent and listed only the tile stubs. Destroying the
root then left those children alive with a destroyed parent, and the old parent
listing a destroyed child.

The documented caller makes the root with createEntity() and attaches the scene at
once, so it never saw this. The scene deserializer did: it attaches the stream
scene to an entity that it then places and parents from the saved file, so a scene
saved with a root that was moved or parented after loading, which the editor
allows, came back with the broken links.

The components are now registered only for a root that has none, as the asset
nodes do. The root keeps its parent and its children.

The replacement also put the root back at identity, and that part is needed: the
streaming and culling checks read the manifest's tile bounds as world-space values,
and a tile under a displaced root fails the frustum gate and does not load. The
root's own position, rotation and scale are therefore still reset, now through the
transform API, which moves the entities under the root at once. A warning says when
that discards a transform, and when an ancestor still keeps the root away from
identity: the hierarchy is the caller's and is not changed.

The root is prepared under the world mutation gate, like the stub batches. A scene
being deserialized holds the gate until it has restored the saved transform and
parent of the root, so the root is always prepared after them, whatever thread the
manifest arrives on.

Co-authored-by: Javier Segura <javier@miolabs.com>
Brings the fork's develop up to upstream ffb248d: 23 commits, from the
animation foundation and character deformation stack through the release
0.22.0 commit (untoldengine#1281 to untoldengine#1305, with untoldengine#1289 and untoldengine#1300 already here as picks).

18 files conflicted. In each, the fork's side was an earlier copy of work
that upstream now holds (reach IK, the component pool fixes, the vertex
stream binding, the lock census, the exporter, the shader libraries), so
all of them take upstream's version. The merged shader sources are
upstream's, and so are the seven shader libraries.

After the merge the fork's tree is upstream's plus 13 proposal documents
under docs/proposals.
@miogds
miogds merged commit c4724b5 into develop Oct 5, 2026
4 of 5 checks passed
@miogds
miogds deleted the sync/upstream_develop_1005 branch October 5, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants