Repository navigation
Sync fork develop with upstream: release 0.22.0 (#1305) - #70
Merged
Merged
Conversation
…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.
This was referenced Oct 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the fork's
developup to upstreamdevelopatffb248dd(untoldengine#1305, the release 0.22.0 commit). The fork lacked 23 upstream commits.What it brings
ae6df3e7), and reach IK with a target per chain ([Feature] Reach IK: a target per chain, in world, model, shoulder or ground space untoldengine/UntoldEngine#1283)..untoldof a pack loaded once ([Performance] Load each .untold of a pack once untoldengine/UntoldEngine#1290), objects under a pixel tall left out ([Performance] Leave objects under a pixel tall out of the frame untoldengine/UntoldEngine#1297), a mesh's Metal buffers read once ([Performance] Read a mesh's Metal buffers from MetalKit once untoldengine/UntoldEngine#1298), LOD chains for the models of a pack ([Performance] Automatic LOD chains for the models of a .untoldpack untoldengine/UntoldEngine#1299).Scene.assignand a reader ([Bugfix] Close the data race between Scene.assign and a reader of the slot untoldengine/UntoldEngine#1302), lights keep their transform and hierarchy ([Bugfix] Keep the transform and hierarchy of an entity that becomes a light untoldengine/UntoldEngine#1303), the root of a streamed scene stays in its hierarchy ([Bugfix] Keep the root of a streamed scene in its hierarchy untoldengine/UntoldEngine#1304). [Bugfix] Let scenes hold more than 20,000 entities untoldengine/UntoldEngine#1289 and [Bugfix] Release ECS components when they leave the scene untoldengine/UntoldEngine#1300 were here already as picks; they now come from upstream.withExtension(No longer need withExtension for mesh, animation, or Gaussian loads untoldengine/UntoldEngine#1291), the actors and locks note ([Docs] Actors and locks: benchmark, lock census of the frame and rules untoldengine/UntoldEngine#1294), no build for documentation-only changes ([CI] Skip the build, test and lint workflows for documentation-only changes untoldengine/UntoldEngine#1295).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 indevelop.Verification
Locally, on the merge commit:
UntoldEngineTests(1,624 tests, 2 skipped; the external package test passes from a checkout namedUntoldEngine),UntoldComponentKitTests(59).make testexporter: 230 pass.develop(aba6ce4), with the Gaussian twins package, builds against this head and passes its tests (1,071 tests, 1 skipped).After the merge
Scene.assignfix) is covered by upstream [Bugfix] Close the data race between Scene.assign and a reader of the slot untoldengine/UntoldEngine#1302, which this brings.developand need a merge of the new one.