Skip to content

Sync fork develop with upstream: per-eye shading, LOD hidden parts, IBL diffuse (#1285, #1324, #1329) - #72

Merged
miogds merged 12 commits into
developfrom
sync/upstream_develop_1010
Oct 10, 2026
Merged

miogds merged 12 commits into
developfrom
sync/upstream_develop_1010

Conversation

@miogds

@miogds miogds commented Oct 10, 2026

Copy link
Copy Markdown
Member

Brings the fork's develop up to upstream develop at 1085580c ("[Bugfix] Report animation clips that match zero joints on the bound skeleton (untoldengine#1330)"). The fork was eleven commits behind; after this merge it differs from upstream by the 13 proposal documents under docs/proposals only.

What it brings

Merge

One merge commit of upstream/develop, no conflicts: the fork's side since the last sync (#71) is the proposal documents only.

Verified locally on the merge commit

  • Exporter: 270 tests (python3 -m unittest discover -s scripts/tests -t .).
  • Core: UntoldEngineTests 1,693 tests, 2 skipped, 0 failures (run from a checkout folder named UntoldEngine, so the external render extension package test runs too).
  • Component kit: 59 tests.
  • Render: the glass and material shading classes (GlassShadingTests, BaseColorShadingTests, EmissiveShadingTests, EnvironmentReflectionShadingTests, NormalMapShadingTests, HeightMaterialAPITests, NativeFormatMaterialTests): 54 tests, 0 failures, PSNR threshold 33.5. The rest of the render suite is upstream's code and passed upstream's CI at 1085580c.
  • Strict concurrency (scripts/strict-concurrency-guardrails.sh): build passes, 0 warnings.
  • Editor fork develop at its own sync head (miolabs/UntoldEditor, the sync opened alongside) built against this merge commit through a local path dependency: builds, 1,312 tests, 1 skipped, 0 failures.

The user's editor gets the engine with Xcode's "Update to Latest Package Versions" after the merge.

untoldengine and others added 12 commits October 7, 2026 09:00
untoldengine#1316)

* [Patch] Add animation playback helpers for synchronized gameplay

Adds restartAnimation, getAnimationPlaybackTime, and
sampleAnimationJointPosition so callers can replay a one-shot clip
from time zero, read its current playback time, and sample a named
joint in world space at an arbitrary time without changing the
displayed pose.

ClipSampler.sample() also unconditionally wrapped time via fmod, even
for clips whose channels were flagged non-repeating, defeating the
per-channel hold-last-pose logic and causing a completed one-shot
clip to jump back to its initial pose. It now clamps to [0, duration]
when no animated channel repeats, and only wraps for repeating clips.

restartAnimation preserves warmed compiled-pose caches unless the
clip's looping configuration actually changes.

Fixes untoldengine#1314.

* [Bugfix] Stop root motion from wrapping at a one-shot clip's duration

applyRootMotion computed its own channelTime via fmod(currentTime,
clipDuration), unconditionally, regardless of whether the clip's
channels repeat. Once currentTime passed a non-repeating clip's
duration, this wrapped back near zero, which wrappedChannelTime then
misread as a fresh loop and injected a full loop's root displacement
at that instant -- a visible snap right as a one-shot animation (e.g.
a kick) finished, even though ClipSampler already holds the displayed
pose correctly past that point.

Match ClipSampler: clamp to [0, duration] when no animated channel
repeats, only wrap when at least one does.

* [Patch] Update animation clip sampler and root motion
…he mesh's default (untoldengine#1320)

* [Bugfix] Export the material a slot linked to the object shows, not the mesh's default

A multi-material object is split into one mesh per material before export. The
split took each piece's material from the mesh data, so a slot linked to the
object (Blender's slot link Object) exported the mesh's default instead of the
material the object shows. An IFC import gives hundreds of objects one shared
mesh and links each one's own material over it: in the BIM site 771 of 811
object-linked slots differ from their mesh's material, and every classroom chair
came out with the red material its shared mesh holds where Blender shows wood.

Both split paths now ask the object's slot first (slot_material), and a rigged
piece's slot is left reading from its one data material. Documented in
UsingTheExporter.md, checked in scripts/tests/blender/material_slot_checks.py:
the plain split, linked duplicates with their own materials, the export
preparation path and a rigged object.

* [Patch] Resolve a material slot in one place: mesh_object_material goes through _slot_material

The split paths and mesh_object_material resolved a slot the same way in two
places (the object's slot first, the mesh data for what the slots do not
cover). mesh_object_material now takes its primary lookup from _slot_material
and only keeps its own fallback to the first material; the helper carries the
file's underscore convention, since only the split paths use it.

---------

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

The occlusion test drops the entities that the depth pyramid of the
previous frame hides. It projected this frame's entities with this
frame's camera into a pyramid rendered from the previous frame's
camera, with a fixed depth bias as the only allowance for the
difference. Whenever a step uncovered what a near surface had covered
(rising above the ground, backing away from a wall, strafing past a
trunk), far entities landed on texels that still held the near depth
and were dropped until the view settled: on a large site the trees
vanished while the camera flew and came back when it stopped.

The pyramid now records the camera it was built from
(RenderInfo.hzbFrame: the view-projection with the scene root and the
camera's position; in stereo the eye rendered last), and the test
projects with that camera. An entity seen from both places is tested
where the pyramid holds its own depth, however the camera moved in
between; an entity the old camera could not see is kept; what a step
uncovers from behind a near surface shows one frame late. After a step
beyond HZBOcclusionCulling.maxCameraStep (0.5 units by default,
setRendering(.occlusionCullingMaxCameraStep(_:))) the pyramid says too
little about the frame and the test is skipped: the frame draws what
the frustum keeps. The kernel and the bias are unchanged; the unused
hzbPyramidOverride parameter is gone.

HZBOcclusionReprojectionTests: a box seen over a near wall from both
places stays visible when the camera rises (dropped before this
change), a box behind the wall stays hidden across a small step, the
test is skipped for the frame after a jump and runs again from the new
place, the camera of a view matrix, the pyramid's record, the setting.
CullingTest's direct runs of the pass set the pyramid's camera.

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

* [Performance] A snapshot of the scene for the render passes

A pass asks the same few things about thousands of entities: is the entity
still there, which of a dozen components does it have, where are its render
and transform components. Asked through `scene`, each question takes the
scene lock, copies the scene and looks a component id up.

RenderSceneSnapshot reads the scene once, looks up the ids and the storage
of the components the passes use once, and answers from what it took:
- entity(_:) says whether an id is a live entity and gives its traits
  (RenderEntityTraits, from the entity's component mask)
- drawComponents(of:) and the other accessors hand out the scene's own
  component objects, so their values are always the current ones
- forEachEntity(with:) walks the entities that have a set of components,
  in the order of their indices
- SceneChannelRenderModeMemo asks for the render mode of a channel set only
  when it differs from the one of the entity before

A snapshot lives for one pass. Nothing uses it yet.

* [Performance] The cull and the shadow passes read the scene through a snapshot

The culling gather, the fit of the shadow cascades and the choice of the
shadow casters walked every render entity through `scene`: about a dozen
locked lookups per entity, per pass and per cascade. They now build a
RenderSceneSnapshot when they start.

- the two culling gathers walk the snapshot, so the list sent to the GPU
  cull follows the entity indices instead of the order of a hash set
- the casters of a cascade, of the spot light and of the point light are
  chosen from the snapshot and keep their components for the draw loop
- the shadow draw loops read the camera position and the projection once
- a mesh's vertex streams are bound from the deformation component and the
  skeleton flag the pass already has, and MTKMesh.vertexBuffers, which
  builds an array on every read, is read once per mesh

What is drawn does not change.

* [Performance] The model, transparency and wireframe passes read the scene through a snapshot

For every visible entity the opaque passes asked `scene` about fifteen
things before the first draw, and five more for every draw (two fades, the
ghost opacity, the deformation buffers, the skeleton), besides settings that
sit behind a lock of their own.

- an entity's draws start from one read of the snapshot: its traits answer
  what it is (camera, light, gizmo, batched, fading) and its components come
  from the storage the snapshot took
- what every draw of a pass shares is read when the pass starts: view and
  projection, camera position, parallax quality, the debug colour switches
- what every draw of an entity shares is read once per entity: its fades,
  its deformation buffers, its opacity on a ghost channel
- an entity whose LOD is not fading draws its meshes without building the
  list of fade draws
- the wireframe and occluder shell filters and the tile statistics reject
  an entity from its traits

What is drawn does not change: the reference images score the same, and new
render tests pin the per-entity decisions (hidden and wireframe channels,
ids that are gone, tile and mesh fades).

On a site of 21,576 render entities (release build, 1920x1080, M4 Max), with
the two commits before this one: culling gather 7.5-8.7 ms to 1.5-1.7 ms,
encoding 69-117 ms to 17-28 ms, frame 93-137 ms to 30-46 ms.

* [Performance] The render entity traits no longer declare Sendable

SwiftFormat 0.60.1 (the version CI runs) rejects an explicit Sendable on a
non-public struct, and an internal option set of one UInt32 is Sendable by
inference anyway.

---------

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

* [Feature] Select LOD levels by the size of the entity on screen

The LOD chain of a pack model gives each level a screen size, and the loader
turned it into a distance for each placement as the pack loaded: right for
the scale and the field of view of that moment only. A placement scaled
afterwards, or a zoom, kept switching at the old distances.

A LOD component can now select by screen size
(LODComponent.selectsByScreenSize): each level ends at the distance where
the entity covers the screenPercentage of the next one. The size is read as
each LOD pass runs, from the sphere around the entity's bounds under its
world scale and from the projection in use. It is a share of the viewport
height whatever the resolution, so a denser display draws the same levels.
The bias and the hysteresis act on these distances as they do on
maxDistance.

The placements of a pack select this way. The parts of a model with several
nodes carry the radius of the whole model (LODComponent.screenSizeRadius)
and change level together. The distance of the load stays in maxDistance:
it serves an orthographic view, and a level without a screen size.

LODLevel.screenPercentage was stored and never read. A component selects by
it only when told to, so entities that were given distances keep them.

* [Patch] In stereo, take the LOD reach from the eyes' projections of the last frame

The LOD update runs before the eyes are rendered, so in stereo the reach it
took from renderInfo.perspectiveSpace was the second eye's of the previous
frame, and on the first pass of a headset session the window's projection.
The update now takes, in stereo, the larger of the two eye projections'
reaches from the frame rendered last, and until an eye has been rendered (the
projections are still the identity) the stored distances apply, as under an
orthographic projection. A headset's field of view does not change between
frames.

---------

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

Covers dependency setup, registering the backend before the renderer,
collider sizing (including the mesh-scale-vs-collider-radius pitfall),
automatic stepping, resetting a body via the backend's resetBody side
channel, and the current damping limitation, cross-checked against the
CoolBasket and ImmersiveGol demos.
…rer (untoldengine#1328)

Xcode's bundled Python ships pip 21.2.4, which rejects --break-system-packages
(added in pip 23.0.1) and aborts the target before it can even upgrade pip.
Detect flag support via pip --help instead of hardcoding it, and honor
UNTOLD_PYTHON so the installs target the same interpreter the tests use.

Fixes untoldengine#1325

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* [Bugfix] Shade each XR eye from its own position

In XR the lighting, model, batch, transparency, sky and render-extension
passes took their view vector from CameraComponent.localPosition, which
UntoldEngineXR sets to the head centre once a frame for streaming
(setXRCameraWorldPosition). Both eyes were shaded as if seen from
between them, so specular highlights and Fresnel had no stereo
disparity and read as painted on the surface up close.

renderXR now stores the position of the eye it draws, taken from that
eye's view matrix, in renderInfo.xrEyeCameraPosition, and the shading
passes read shadingCameraPosition(_:), which returns it (through the
scene root, like before) or the camera's position outside XR; the
non-XR draw clears it. Streaming, LOD, octree and culling keep the
head centre. Gaussian splats keep it too: their colour is evaluated
once per frame for both eyes in the shared preprocess.

* [Patch] Write the xrEyeCameraPosition comment as a doc comment

SwiftFormat 0.60.1 (the version CI pins) wants a doc comment on a lone
property declaration; the lint job failed on it.

---------

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

* [Bugfix] Keep a model's hidden parts hidden at its coarse LOD levels

A model's meshes are simplified one by one, each within the allowance of the
level, and a part a short way behind another (a car's inner skin 0.8 mm under
its roof, its door panels behind its doors) shows through once the allowance
exceeds the gap: the parked cars of a BIM site grew black patches with the
distance.

The cook now sinks what is hidden before each level is simplified
(UntoldMeshLODHiddenParts). A vertex is hidden when its own side is covered
(the rays along its normal and in a cone round it all meet another face of the
model) and a seen face of another, solid mesh is in front of it within three
allowances; it is moved away from that face, along the face's normal, by twice
the allowance where it touches the face and by less up to the edge of reach,
so that the two cannot cross. What is seen is never moved; nothing is sunk
away from glass; a sink stops two allowances short of any seen face in its
way; the sinks are averaged over each connected piece, with the seen vertices
held still, so that a piece hidden in one part and seen in another bends
instead of tearing; and a piece that is sunk for the most part is simplified
on its own share of its mesh's target, since bent by its sinking it would
otherwise keep its triangles and take those of the pieces beside it.

A level also gives every vertex whose normal is more than 30 degrees from the
faces around it (a vertex kept from a groove that is gone) the normal of
those faces, turned to the side of the vertex's own so that a mesh wound the
other way round keeps its normals pointing out; a hard edge keeps both.

On the site's pack (4,779 models) the cook takes 21 s instead of 6, the
coarsest levels hold 3.4 % more triangles, the levels' errors do not change,
and the cars are clean at every level.

* [Chores] Satisfy the CI lint and the strict-concurrency guardrail

CI's SwiftFormat 0.60.1 wants a key path for the trivial map closure and reads
the `self.` of `let rings = self.rings(for:)` as redundant, where it told the
local from the method: the method is now `cellRings(for:)`. CI's Swift 6.3.3
warns of a `var` captured by the closure `concurrentPerform` runs, which the
guardrail counts as a failure: the covered vertices are now a `let` built by a
filter. No behaviour changes; the cooker suite passes as before.

---------

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

* [Bugfix] Use single-bounce reflectance for IBL diffuse energy conservation

computeIBLContribution attenuated diffuse by the multi-bounce-compensated
reflectance from environmentReflectance, which conflates two different
things: the energy specular's own approximation needs restored via later
bounces, and the share a surface diverts away from diffuse in the first
place. Factor the single-bounce term out into singleBounceReflectance and
use that for the diffuse term instead, keeping the compensated value for
specular only.

Fixes untoldengine#1296

* [Chores] fixed formatting
…keleton (untoldengine#1330)

A clip whose channels never resolve against the target skeleton's joint
paths (namespace prefix or hierarchy mismatch) previously registered and
played silently: currentAnimation set, currentTime advancing, every joint
sampling its rest pose, with no error at all. Adds
.animationClipNoMatchingJoints and a diagnostic check wired into
changeAnimation, restartAnimation, and the two auto-select-on-registration
paths; registration and playback still succeed, this only makes the failure
visible.

Fixes untoldengine#1318
@miogds
miogds merged commit 100434f into develop Oct 10, 2026
5 checks passed
@miogds
miogds deleted the sync/upstream_develop_1010 branch October 10, 2026 08:57
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