2026-08-08 #405
markaren
announced in
Announcements
2026-08-08
#405
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This release turns threepp from a three.js-style C++ rendering library into something closer to a simulation platform. It adds two standalone applications (a scene editor and a headless-capable player), a Python package with an owned GPU-vectorized RL stack, a PhysX robotics layer (articulations, URDF/xacro, joints, direct-GPU batching), a proprioceptive sensor suite, and Gaussian-splat rendering — while the OpenGL backend gained post-processing and four PBR features it previously advertised but never shaded, and the Vulkan backend collapsed to a single deferred renderer that doubles as a synthetic-perception instrument. Two entire backends were deleted in the process: the WebGPU renderer and the Vulkan path tracer. Performance figures below come from the developer's own machine (an RTX 4070 unless stated otherwise) and are quoted as measured there, not as properties of the library.
Highlights
threepp_editoris a full authoring application — viewport, hierarchy, inspector, undo/redo, Play mode, Python behaviour scripting — saving plain three.js "Object" JSON so an authored scene runs in a plain threepp program with no editor present. Built by default when threepp is the top-level project; seedoc/editor.md(2,618 lines).threepp_playerruns an editor-authoredscene.jsonas independent episodes, records sensor CSVs, and exits nonzero if any script raised or the document would not play.import threepp as tpgives the scene graph plus headless render-to-numpy, PhysX bindings, andthreepp.rl(GpuSim + VecTask + PPO) with no rl_games, rsl_rl or Gym dependency. Wheels for Windows and Linux (CPython 3.10–3.14) publish to PyPI on every date tag —pip install threepp— carrying the GL renderer, the deferred Vulkan renderer and CPU PhysX; the editor ships separately aspip install threepp-editor(Windows).SplatCloudrenders on GL 3.3 and through a deterministic Vulkan compute tile rasterizer, with two scan loaders (INRIA.ply, PlayCanvas SOG) and dynamic LOD.cmake --preset gl,--preset vulkan,--preset vulkan-aaa,--preset python,--preset wasm, and nineci-*presets, all tracked inCMakePresets.jsonand used by CI itself.Scene editor and player
threepp_editorcovers viewport with orbit/pick/TransformControls (local-world space, snapping), hierarchy with drag-reparent, an inspector for object flags/transform/material/geometry/lights/cameras, a bottom panel (console, asset browser, live sensor readout, script tabs), a first-party ImGui file browser, and drag-and-drop for scenes, HDRIs, models, scripts and textures. Behind-DTHREEPP_BUILD_EDITOR=ON, which defaults to a top-level GLFW build.key=value;…string under auserDatakey through a shared codec — deterministic, so unchanged values stay byte-identical and saved files diff cleanly.start(Scene&),update(float),stop()..pyclass to an object and getstart/update/fixed_update/stop, collision and trigger callbacks, coroutines withwait()/until(), six debug-draw calls, and handles onto the running PhysX world and sensors. The bindings are the same translation units the wheel is built from, so one pybind11 type registry serves both. RequiresTHREEPP_EDITOR_WITH_PYTHON(ON by default, self-disabling when Python or pybind11 is missing).threepp_playerruns the same document headlessly.--episodes=Nplays it back to back (each a full play/stop cycle, so episodes are independent by construction),--record=DIRwrites one CSV per sensor per episode,--headlesscreates but does not show the window so vision sensors still scan, and--secondscounts simulated seconds read off the physics world's clock rather than summed wall-clock deltas.threepp_player --headless; with the conveyor session removed, logs reach the bay and none is ever cut. The per-episode line reports the conveyor count when a document has any.apps/editor/tools/, andtimber_yard_author --checkauthors twice and compares bytes.--selftestdrives the editor through its acceptance passes and prints PASS/FAIL per assertion (730 check sites), compiled unconditionally.--screenshot=,--shot=,--keys=,--bench=N,--play,--frames=Ncover headless capture and timing.Vulkan renderer
VulkanPathTracer(RT megakernel, ReSTIR DI/GI, photon caustics, à-trous denoiser) was retired; the deferred G-buffer renderer is the sole Vulkan path, split from one monolith into nine translation units behind a single pImpl. Behind-DTHREEPP_WITH_VULKAN=ONor thevulkanpreset.addView/removeView/setViewCameraattach persistent extra views; everyrender()produces the primary and each added view from one scene build in one queue submission. Measured at 640×400 primary: 0 secondary views 5.699 ms/frame, 3 secondary views 18.157 ms — 2.25× the pixels for 2.19× the frame time. Secondary views are deliberately plainer: native resolution, built-in temporal resolve only, no DLSS/FSR, occlusion culling, DoF, lens model or splats.readGBufferAOVcopies a G-buffer attachment in its native format — Depth (float32 reversed-Z), Normal (fp16×4, roughness in w), Motion (fp16×4), Ids (uint16×4), Albedo (unorm8×4), SplatDepth (float32 view distance). The Ids.ychannel is a per-object instance id stable across add/remove/hide/LOD;.zis an 8-bit semantic class.readViewGBufferAOVgives the same for each camera, so N viewpoints yield depth, instance and semantic ground truth for one simulated instant.setPhysicalCameraderives exposure from aperture/shutter/ISO via EV100;setPhysicalLightUnitsmakes DirectionalLight lux, Point/Spot lumens, RectArea nits.cameraIntrinsics()reports fx/fy/cx/cy in OpenCV convention,setLensDistortionimplements Brown-Conrady and Kannala-Brandt fisheye applied to both the display and the AOV readback (integer and depth AOVs resample NEAREST), and sensor noise is modelled in the electron domain (full-well, read noise, dark current, PRNU), deterministic under a seed. Caveat: the default sunny-16 triplet is an exposure multiplier of 2.6e-5, correct only when the scene is lit in photometric units — a scene lit at intensity 1–5 goes black. UsesetPhysicalLightUnitsand real light values together. Barrel distortion also needssetLensOverscan(1.15–1.3) or frame corners clamp and smear. All of it is off by default and byte-identical when off.scene.fognow drives a froxel volume (128×72×64, exponential [0.25, 512] slices) that replaced two per-pixel marches: directional lights get one RT shadow ray per froxel, and clustered point/spot lights scatter in fog for the first time. Sun shafts and aerial perspective follow automatically.setVolumetricFogis a no-op kept for compilation. Clustered light culling removed the 8-per-type caps.setCloudsadds an analytic Perlin-Worley cloud deck with ground shadows in a half-res compute pass (a dense overhead view went from ~6–7 ms in-shade to ~2.2 ms); off by default and image-identical when off.taa_resolvere-anchors current-frame reads at the unjittered pixel center, and DLSS/FSR jitter is converted per axis. Measured on a static camera by 16-frame phase correlation: ±0.9 px / frame-diff 3.2/255 down to TAA 0.037 px / 0.72, DLSS 0.012 / 0.28, FSR 0.008 / 0.23.setGbufferMsaa(1|2|4)rasterizes the G-buffer at N samples and resolves by dominant sample; the load-bearing part is that the raster runs unjittered above 1 sample. Default 1 is byte-identical to the old path. Material textures now use 16× anisotropic filtering in every mode;setTextureAnisotropy(1)forces isotropic.THREEPP_NO_BC=1andTHREEPP_NO_PACK=1are same-binary escape hatches.setDenoiseis documented as an A/B pipeline selector that changes material appearance, not a quality dial.dynamic_caststorm (~89 ms) and per-tile queue drains (~37 ms); memoizing byMaterial*and batching one-shot command buffers took a 1,200-frame fjord flight from p95 ~94 → 35 ms and max ~150 → 62 ms. A tet-skinning upload race, TLAS scratch use-after-free and texture-cache address recycling were fixed alongside.-DTHREEPP_WITH_DLSS=ON/-DTHREEPP_WITH_FSR=ON(Windows + Vulkan only) or thevulkan-aaapreset. Known open issue: emitter/fog silhouette artifacts appear on both and not on TAA, suspected to be an exposure-domain split.sampleHeight()opt-in.THREEPP_VULKAN_VALIDATIONnow overrides the Debug/Release default in both directions — RelWithDebInfo previously could never report a VUID.FrameTimingscovers twelve GPU stages and three CPU ones, including a three-way splat split.THREEPP_VK_MEMDUMP,THREEPP_VK_SPLAT_CHECKSUM,THREEPP_DENOISE=0,THREEPP_NO_BC,THREEPP_NO_PACKandTHREEPP_VK_ANISOare same-binary A/B switches. One unresolved item: the Vulkan editor selftest exits -1 under conditions that were never pinned down.OpenGL backend and core
threepp/postprocessing/module ports r129's addon framework:Pass, the composer's ping-pong pair,RenderPass,ShaderPass,ClearPass,MaskPass/ClearMaskPass,TexturePass,SavePass,UnrealBloomPassandBokehPass. Two deliberate departures from three.js: the composer always owns the final draw to screen so the sRGB encode happens once, andShaderPasssuppresses its implicit clear while a stencil mask is active. Outline and SSAO passes are explicitly deferred. Built unconditionally.RenderTarget::Options::samplesallocates a multisampled framebuffer and resolves on unbind, clamped toGL_MAX_SAMPLES, withglReadPixelsredirected to the resolve framebuffer. Two defects fixed on the way:setSizereleased resources through the latchingdispose(), so a second resize was a no-op; and the depth renderbuffer format is now sizedGL_DEPTH24_STENCIL8in one place for both sides of a target, since a depth resolve blit requires matching formats.IBLSheenBRDF(it previously sat in an#elseagainst GGX and replaced base specular, gated on an optional loaders never wrote).specularIntensity/specularColorreach the shader via apbrSpecularflag. Iridescence gets a Belcour & Barla chunk.ToneMapping::AgXwas a selectable enum that fell throughdefault:and emitted Linear. glTF assets carryingKHR_materials_sheenorKHR_materials_specularrender differently on GL now — that is the fix, not a regression.F_Schlickswitched from Epic's exp2 fit to exact pow5 everywhere, becauseSchlick_to_F0(needed by iridescence) is the algebraic inverse of the pow5 form only. Only two call sites read it and the error peaks around 0.003 absolute at mid angles.shadowMap().type = ShadowMap::VSM.shadowMap().enablednow rebuilds material programs — previously disabling shadows froze the last-rendered ones on screen and enabling from a disabled start did nothing, and switching to VSM after the first frame null-dereferenced. Alpha-tested casters now get their ownMeshDepthMaterial, so a leaf card shadows its silhouette instead of a rectangle.BufferAttributegains anAttributeTypeenum withUint8/Int8/Uint16/Int16aliases and adenormalize()matching GL/Vulkan UNORM/SNORM rules;FloatAttributeViewreads any attribute as float (zero-copy when already float).compressAttributes()/compressSceneAttributes()narrow normals and tangents to snorm16, UVs to unorm16 when inside [0,1], colours to unorm8, skipping anything lossy and skipping skinned/displaced/morph/tet geometry. GL uploads all of them natively, removing threethrow std::runtime_error("TODO")paths.Matrix4::multiplyMatricesis SSE2 on x86: 16.0 ns → ~4 ns per multiply under MSVC /O2, bit-exact against the scalar path (160k products compared byte-wise, zero differences).THREEPP_NO_SIMDforces scalar; NEON is deliberately not done because FMA would make it 1-ulp rather than bit-exact.Object3Dstoresmatrix/matrixWorldby value with the publicshared_ptrhandles as non-owning aliases — same source API, 2 heap allocations and 2 control blocks per node gone. At 100k nodes: build −8%, dirty-subtree traversal −8..10%, teardown −10%; the honest trade is a matrixWorld-only linear sweep ~30% slower andsizeof(Object3D)984 → 1112.Object3Dalso now derivesstd::enable_shared_from_this<Object3D>, which fixed a deterministic segfault where pybind11 wrapped C++-created children in non-owning instances — and which meansshared_from_this()must never be called on a stack-allocatedObject3D.remove()/removeFromParent()route through onedetachChild()that moves the owning reference to a local first;add()/addRef()reject null, self and ancestor insertion (a self-cycle used to recurse forever); the move constructor no longer leaves null matrix pointers in the parent's child list.EventDispatcher::subscribe(type, fn)returns aSubscriptionwhose destruction removes the listener, with the registry owning the callable. Listeners removed during dispatch are no longer called (three.js still calls them; here that would be a use-after-free).Object3D::id,Material::idandBufferGeometry::idare atomic — loaders detach worker threads, and a torn read-modify-write handed two materials the same id, which madeGLRendererskip the second one's uniform upload. Raycast and triangle scratch moved from 17 file-scope statics tothread_local;BufferAttribute's two shared scratch vectors became function locals.orthoDirectionuniform makes every pixel sample the camera's forward. Measured before the fix on a 64×64 target, 4,080 of 4,096 pixels were clear colour with an env map set. three.js has the same gap.threepp/extras/DataUtils.hppportstoHalfFloat, andImageDatagained auint16_talternative plusImage::isHalfFloat(), so half-float textures can take CPU pixels. The port saturates on overflow rather than reproducing r129's expression, which turns 70000.0f into negative infinity. Known gap: the Vulkan material-texture path does not accept half-float DataTextures and now says so.Interpolant::settingswas never initialised andgetSettings_()dereferenced it;CubicInterpolantported three.js's out-of-range-index idiom literally, which in C++ is an out-of-bounds read at both ends;KeyframeTrack::setInterpolationnever assigned;AnimationMixer::clipActionwrote to a local copy and so allocated a fresh action every call. Multi-primitive glTF meshes now fan mesh-level morph weights to all primitives.GLUniformsvec2/3/4 caching used&&where it needed||, leaving stale uniforms; a mipmap min filter on a texture that can never have mips made it GL-incomplete and every sample black (RoadNetwork, FacadeTexture, DetailTexture);BufferGeometry::applyMatrix4had its bounds test inverted;Mesh::raycastread vertex B's UV from indexc;renderOrderis nowint, so a negative order no longer wraps and sorts last;TransformControlsGizmosetrenderOrdertonumeric_limits<int>::infinity(), which for an integral type is 0.exit(EXIT_FAILURE)killed the process — inside the Python module, a dead interpreter with no traceback. Hints are now reset explicitly, and the canvas/glad path throws instead of callingexit(). The same leak affectedGLFW_SAMPLES. An app that grabbed the pointer also left the OS cursor hidden after a clean exit;CURSOR_NORMALis restored before window destruction.Gaussian splatting
threepp::SplatCloudis aMeshover a new publicInstancedBufferGeometry, with per-splat data in three DataTextures (mean+opacity, covariance, SH). It draws on the GL backend with no renderer edits — though making it a plainMeshrequired filling in threeif (false)placeholders in the GL renderer, soInstancedBufferGeometry/InstancedBufferAttributeandglVertexAttribDivisorare now generally available.SplatLoaderreads INRIA 3DGS.plydriven by the header's property table, handling channel-majorf_rest_*, pre-sigmoid opacity, pre-exp scale and w-first unnormalised quaternions, and preserving unconsumed properties as named per-splat float arrays.SogLoaderreads PlayCanvas SOG v2 / SuperSplat from a directory, ameta.json, or a.zip/.sogrecognised by PK magic — with its own central-directory reader accepting stored entries only..plyit decodes to bit-for-bit (~92% smaller), loading in 1.64 s versus 1.43 s warm / 2.97 s cold. Both decode to ~1.2 GB resident, so this is disk and cold-open, not decode speed.SplatPassis a 12-stage compute pipeline (project → prefix-sum scan → deterministic expand → 8 radix passes → tile ranges → 16×16 tile composite) that read-modify-writes the HDR scene buffer between deferred shade and DoF, so splats get DoF, bloom, tone mapping and TAA through the same code as everything else. Determinism is by construction (prefix-sum expansion, stable LSD radix, no subgroup ops) and gated by a test asserting bit-identical keys, payloads and pixels across processes.SplatQuatreplacedstd::vector<Quaternion>(Quaternion carriesfloat_viewcomponents and astd::function, costing 80–112 bytes per splat of unused change-notification depending on standard library — 128 B total on MSVC, 96 B on libstdc++), andSplatCloudstopped deriving fromInstancedMesh, shedding a 64 B host + 64 B VRAM identityinstanceMatrix. The GL render is byte-identical across both changes.setSplatDepthAov(true)adds an expected-depth AOV in world units, written where coverage exceeds 0.5. It is an expected value, not a surface — it sits behind the visible front of a cloud by roughly its own thickness, localizing a wall well and a canopy poorly. Off by default because a full-res r32f per frame in flight is ~25 MB at 1080p, and toggling reallocates render targets.SplatData::removeOutliers()andreorderMorton()are opt-in, deterministic and scale-free (every threshold a ratio against the cloud's own percentiles). Neither loader calls them. Morton is off by default because it measured worse on the GL draw-order path (82–87 ms vs 50.9 ms in file order on the 5M scan) and made no difference on Vulkan.removeOutliers()and a half-turn about X applied by default (COLMAP-derived captures are +Y down, including SOG).SplatCloudis not serialized:ObjectExporterrefuses the type by name with a warning, and the editor warns before Save, Play and Stop that the cloud will not survive.SplatPass::record()runs per cloud, so a second cloud costs ~1.3 ms flat (1/2/4/8 clouds at constant total splats: 7.1 / 9.6 / 13.2 / 16.4 ms). Splats cast no shadows, appear in no reflection, contribute to no probe GI, are invisible to the RT sensors, and secondary views skip the pass entirely. Python bindings landed late in the range:SplatLoader/SplatData/SplatCloudon both backends, withsubmit_rangesexposing the LOD mechanism.ImageLoadersniffs the format, so every existing image caller inherits it. Decoder only — nothing in the tree can write a WebP.PhysX and robotics
Articulation/ArticulationLinkbuild PhysX reduced-coordinate robots: add the root, then children with inbound revolute or prismatic joints, thenfinalize(). Handles limits, PD drives, joint friction (defaulted to frictionless — PhysX's default silently faked balancing tasks by holding a pole against its own gravity torque), episodereset(), and batched joint I/O that collapses roughly 36 pybind calls per robot per step to 3.dofOrder()maps CPU add-order onto the GPU/cache DOF order so a GPU-trained policy reconciles with CPU deploy.loadArticulation()imports a URDF or xacro straight into a simulated robot, through the same parserURDFLoaderuses. Box/sphere/cylinder collisions map directly and<mesh>collisions cook to one convex hull (they used to become a bounding box — a chair leg collided as a solid slab).renderVisuals=falseskips loading visual meshes, which dominates a large batch build.URDFArticulationOptions::scalefolds a uniform length scale into the description before a single actor exists, deliberately not scaling mass (URDF states kilograms whatever unit the geometry was drawn in) or revolute limits.Jointis one PxD6-backed maximal-coordinate type covering fixed, revolute, prismatic and spherical (plus distance), with limits, a force-mode PD drive, a break force/torque, and joint-space readout thatJointEncoderandForceTorqueSensorread.breakWrench()exists because after a break,PxConstraint::getForcefreezes on the breaking step's value rather than returning zero —reactionForce()now returns zero once broken and the failure load is latched separately.watchContacts,watchTriggersandwatchConstraintBreaksare handle-based; contacts are off until watched, so a world with no watchers pays nothing. The trigger-shape flip is guarded bycanBeTrigger()before the flag is set, because PhysX refuses triangle-mesh and heightfield triggers via an error-stream message rather than a return value, and the two flags are mutually exclusive — a naive raise leaves a shape that neither collides nor triggers.PhysxGpuBatchwraps PhysX's direct-GPU API: one call reads all articulations' joint positions/velocities, root pose or per-link poses into a CUDA device buffer, or writes all drive targets from one — hand a torch CUDA tensor'sdata_ptr()straight in, withwriteSubset()for partial episode resets.Settings::cudaContextlets PhysX adopt the host framework's context, which is required for correctness (a separate PhysX context leaves cuBLAS/cuDNN unable to launch). Under direct-GPU mode the per-actor CPU getters throw with a message pointing atPhysxGpuBatchrather than returning stale state. BehindenableDirectGpu, off by default.decomposeConvex()returns one vertex array per hull behind an interface free of both PhysX and VHACD headers, andPhysxWorld::addCompound()builds one rigid actor carrying several convex shapes with mass and inertia computed once from the union. Deliberately synchronous — V-HACD's async mode returns before the hulls exist, which is wrong behind a Play button. Behind the vcpkgphysxfeature, which now also pullsv-hacd.updateMatrixWorld()and read the cached parent world matrix — stale for a body added before the first render, so the actor spawned at local coordinates.add()/addStatic()also decomposed the world scale and then dropped it, so a scaled mesh collided at unscaled size.removeActorscrubbed object bindings but not instanced ones;onPreSubstep/onPostSubstephad no unregister and both vehicle types register a callback capturingthis;~PhysxWorldleft registered sensors holding a dangling world pointer; and aPhysxWorldwhose constructor threw leaked itsPxFoundation, so every later world in the process failed with "Foundation object exists already" and poisoned the rest of the test binary.PhysxVehicleBase<SettingsT>factors the shared PxVehicle2 stack out of both drive types (2,050 → 1,445 lines across three headers), with telemetry bit-identical over 900 fixed-dt frames. Separately, every heap-allocated vehicle NaN'd within one frame on Linux/GCC:PxVec3/PxQuat/PxTransformhave user-provided default constructors that deliberately leave data uninitialised, so{}member initialisers on the state arrays were a lie. Proven by filling the heap with0xFFand finding matching poison runs; MSVC's young heap handed out benign pages, which is why Windows looked fine.smoothTimestepexists as an opt-in and is defaulted off after measurement: low-passing dt advances the accumulator on a fictional timeline while the frame is displayed at real wall-clock time, so on a chase-cammed vehicle under vsync the per-frame velocity jerk went up roughly an order of magnitude.enableTgsPcmturns on the GPU contact model (TGS + PCM + stabilization) for a CPU world, so a GPU-trained policy deploys onto CPU against the same physics.Sensors and kinematics
Imureports body-frame angular velocity and true specific force including lever-arm terms (a level sensor at rest reads +g, free fall reads ~0).JointEncoderadds tick quantization and differentiates velocity from the quantized reading, so standstill chatter is reproduced.ContactSensorkeeps a refcounted per-shape-pair latch that survives PhysX putting a resting pair to sleep.ForceTorqueSensorreads the solver's incoming joint force from an articulation cache, orPxConstraint::getForceon a plain joint. Header-only underthreepp/extras/sensors/, sampled from inside the fixed substep; the four body/joint sensors need the PhysX SDK on the include path.static std::mt19937seeded byrandom_deviceat four sites — OS-seeded, shared across every sensor instance in the process, and using a distribution that is not portable across standard libraries. Every sensor now owns a SplitMix64 stream seeded from a declarative model, and every measurement is stamped with sim time. All-zero noise passes the clean value through bit-for-bit and draws no random numbers.vkDeviceWaitIdle, which does not cost the trace but costs every frame queued behind the fence: a 10 Hz VLP-16 cost 30.1 ms per scan of which the trace was 1.2 ms and the wait 28.5 ms.scanBegin/scanReady/scanCollectover a ring of four slots replaced it — p95 33.75 → 16.28 ms, max 36.3 → 17.1 ms, and interleaved against a sensor-stripped control the LIDAR now costs 15.29 vs 15.01 ms median. The synchronousscan()keeps its blocking contract; a raster backend delivers on the frame it fired, so code written against the pair works on both.LidarSensorused to crash on Vulkan: it calledsetRenderTarget(a warn-and-ignore no-op there), rendered nowhere, then read back six never-filled cube faces. It now builds a traced backend from the same model. The two sensors' duplicated protocol was hoisted intoTracedRasterVisionSensor<PointT>. Honest gap: on a raster backend aLidarReturncarries only position and distance — intensity 0, normal 0, instance id −1.threepp/extras/kinematics/InverseKinematics.hppis a header-only, dependency-free damped-least-squares solver (finite-differenced Jacobian, Cholesky on the 6×6 normal system) with Position, AxisAlign and Pose tasks, tool offset, joint limits, a null-space rest posture and a speed cap, solving overchainDofs()only. Writing tests for it exposed three latent defects carried over from the demo code: anti-parallel tool axes were a converged fixed point with zero reported error; the null-space projector leaked posture bias so the arm never converged with a rest posture enabled; and the first step was not trust-region clamped. Not exposed to Python.Robotis a tree. Analytic FK multiplied the flat joint vector in document order, so a gripper's branch was multiplied into the tool pose (IK could "reach" a target by squeezing);getEndEffectorTransform()returned the last declared joint, which for the shipped KUKA iiwa is a base-plate joint — the robot's own pedestal; andfinalize()assumed the first link is the root, which xacro-expanded URDFs violate.Robotnow names a tip, walks the real root-to-tip path, and exposeschainDofs(). Prismatic joints also slid along the wrong axis, with the scene-graph and analytic paths disagreeing.**////%, comparisons,in, ternaries, subscripting,math.*andxacro.load_yaml; a bundled YAML reader handles block and flow collections, typed scalars and!degrees/!radians;$(find pkg)resolves through an explicit registry, then apackage.xmlwalk, thenROS_PACKAGE_PATH/AMENT_PREFIX_PATH. Nothing is silently skipped — the first unevaluable thing ends the run with the offending file and text. Verified against the Universal Robots and Franka ROS 2 descriptions (those clones are not vendored, so those tests skip without an env var). Documented as unsupported: YAML anchors and block scalars, namespaced includes,<xacro:element>/<xacro:attribute>, and the$(eval)parts needing a real interpreter.editor::SensorConfigwrites a rig intouserData["sensor"]as one flat string, covering IMU, depth, LIDAR (Dense plus VLP16 / HDL32E / OS1-64 / OS0-128), encoder, contact and force/torque. Sensors are play-time constructs built from userData at Play and dropped at Stop, which is the determinism story.SensorPlaySessionis PhysX-free and splits by class rather than#ifdef, so a build without the SDK still authors, plays, overlays and records the vision sensors.<collision><cylinder>is Z-aligned per spec (the visual path already honoured this; the collision path produced a collider rotated 90° across its own axis),getMaterialno longer indexes an empty rgba split, meshes referenced by both visual and collision load once and clone, anddiagnostics()/lastError()expose why a load went the way it did.VoxelGrid::nearestwalked a fixed 3×3×3 neighbourhood, inexact for anymaxDistlarger than one cell; it now visits exactly the voxels the query ball's AABB spans, pruning each by squared corner distance.marchingCubesclassifies before computing corner positions and gradients, so only crossed cubes pay;icpPointToPointexploits the Jacobian's sparsity and gained translation/rotation tolerances. On the lidar SLAM example, ICP went 13.7 → 4.7 ms per frame and loop drift 0.121 → 0.024 m, because registering against the dense display map had been pulling the pose toward the map's own accumulated error.Python
python/tree,pyproject.tomland the wheels workflow are new: 30 binding translation units covering math, scene graph, geometries, materials, cameras, lights, textures, loaders, animation, curves, text, helpers, audio, controls, splats, terrain, vegetation and the Vulkan renderer. Behind-DTHREEPP_WITH_PYTHON=ON, thepythonpreset, orpip install ..tp.Canvas(..., headless=True)opens a hidden GLFW window with a real GL context andread_pixels()returns an(H, W, 3)uint8 NumPy array;save_frame(path)writes a PNG. No display, Vulkan SDK, PhysX or CUDA needed.read_depth(float32 metric),read_instance_idsandread_class_ids(uint32, no hashing, 0 = sky),read_normals_float,read_motion, andread_aovs_typedwhich renders once and reads every requested AOV from that frame.frame_timingsdict.threepp.rlis three layers of a few hundred readable lines each:GpuSimhides CUDA-context adoption, the direct-GPU batch and the DOF-order remap (state tensors refresh in place, so a stashed reference stays live);VecTaskowns the step/reset choreography including capturingterminal_obsbefore a partial reset and named reward terms logged per episode;PPOdoes running observation and return normalization, GAE, LR anneal and target-KL early stopping.pip install threepp[rl]pulls torch. Notesim.pyreads the CUDA context throughnvcuda.dlland is Windows-only as written.export_bundle.pywrites weights plus aspec.jsondeclaring the observation terms, action decoding, control rate and plant gains;policy_runner.pyevaluates it (ELU only, raising on anything else). Checked against the torch checkpoint at max |action diff| 4.768e-07. Deployed as an editor behaviour script, the Spot terrain policy walked 8.03 m in 10.04 s at a commanded 0.8 m/s.threepp_editor_scripting, deliberately, so one pybind11 type registry serves both. Three translation units are editor-only — theirRigidBody/SoftBody/Articulation/Joint/Vehiclehandles point at a live play session — so the wheel'sthreepp.editorcontains onlySplinePathand authoring-data readers.__init__.pyi,imgui.pyi,editor.pyi). The generator wraps a pinnedpybind11-stubgenand fails when the emitted stub does not parse or when regeneration dropped symbols the committed stubs declare — the bare CLI exits 0 either way, which is how the previous flat stub silently rotted to 84VulkanRendererlines against today's 365.editor.pyiis hand-written and restored after each run.wheels.ymlbuilds cp310–cp314 wheels for Linux and Windows plus an sdist, rehearses the upload against TestPyPI onworkflow_dispatchas a unique.devNversion, and publishes boththreeppandthreepp-editorto PyPI on a date tag via OIDC trusted publishing — no tokens anywhere. macOS has no prebuilt wheel;pip installcompiles from source there (GL-only).tp.vulkan_available()reports which world you are in; PhysX is CPU-only (the 238 MB GPU runtime stays out — dropping the two GPU DLLs beside the module enablesgpu_dynamics=True). No audio or FBX. Scripts should branch ontp.HAS_VULKAN/HAS_PHYSX/HAS_IMGUI/HAS_AUDIO. Dear ImGui is compiled in.DepthSensor.scanis bound; the LIDAR scan is renderer-side and unbound), Vulkan secondary views have no bindings, and there is noHAS_FBXflag.Loaders and serialization
ObjectLoader/ObjectExporterread and write three.js "Object" JSON 4.5 — the object tree with matrices, layers, visibility, shadow flags, render order and userData; scene fog, background and environment; every object and light type; geometries in parametric and universal form; textures as base64 data-URIs; skeletons; animation clips. Output is deterministic and every serialized uuid is adopted verbatim on load, so identity round-trips. Documents authored by the three.js editor load as-is. Not exposed to Python.RGBELoaderreturns. No OpenEXR dependency and no extra zlib — EXR ZIP chunks are ordinary zlib streams. The PIZ decoder treats input as hostile (every table index, pointer step and run length bounds-checked) and was verified bit-exact against OpenEXR on four files. PXR24, B44, DWAA/DWAB and tiled/deep/multi-part files are rejected by name rather than decoded into garbage.TextureLoadernow routes.exrand.hdrto the HDR loaders — a.hdrpreviously fell through to stb's LDR path and came back tonemapped and clipped.KHR_texture_transformresults and animation input accessors memoized. Bulk memcpy fast paths for packed FLOAT/uint32, file reads sized from file length, and move-aware attribute/index APIs. Added: sparse accessors, thenormalizedflag, bounds validation, sampler filters, multi-scene independence, andKHR_meshopt_compressionunder both the draft and ratified extension names. Fixed:KHR_lights_punctualspot lights (a typo plus nested cone angles), animation clip start times normalized to zero, and a crash on points/lines primitives. Every phase was validated against a structurally byte-identical dump across 13–14 baseline assets, produced by the in-treegltf_dumptool.DDSLoadercomputed mip data size in uint32, so a crafted 262144×262144 header wrapped to 0, passed the truncation check, and wrote far outside a zero-byte allocation (now uint64 with dimension and mip caps).ColladaLoadergained bounds-checked source reads, indexed geometry via tuple dedup (−28% vertices on the youbot model) andfrom_charsparsing (~2× faster).FBXLoaderguards truncated files and matches image extensions case-insensitively.TextureLoader's cache is keyed on path|colorSpace|flipY, so an image used as both an sRGB colour map and a linear data map gets the right interpretation for each.tests/loaderswent from a font test to 17 executables (~7,000 lines) covering Collada, DDS, EXR, glTF, images, OBJ, STL, object JSON, SOG, splat PLY, URDF and xacro. Several suites synthesize fixtures rather than vendoring assets.threepp/utils/GeometryLod.hppis a renderer-agnostic index-only LOD chain generator over meshoptimizer: vertex data is never touched, so every returned index still refers into the caller's original array and a consumer swaps levels by rebinding the index buffer alone — in both the raster path and the BLAS.THIRD_PARTY.mdmaps every vendored, fetched and vcpkg-resolved dependency to its license, reproducing the three.js MIT notice and the embedded typeface license in full. It names the two that are not open MIT: threepp_data's Spot URDF (Boston Dynamics SDK License, simulation use only) and the NVIDIA DLSS SDK (proprietary, and the build copiesnvngx_dlss.dllnext to executables).Procedural content
TileTerrainis a quadtree-LOD terrain system (header-only) where tiles subdivide by camera distance with hysteresis and everything is pulled through a three-functionTerrainProvider, so procedural noise, eroded generator output and real DEMs drive the same machinery. Each bake makes one provider sweep into a height lattice and derives positions, normals, splat, band weights, normal map, height AABB and error metric from it. LOD borders are watertight (neighbour level delta ≤ 1, border verts conformed, skirts as backstop), bakes run on bounded async workers, and a split only applies once every replacement is ready.heighttags. Packs are ~64 MB each and are not committed; you must run the fetcher.road::RoadNetworkcomposes per-polyline corridor flattenings into one ground-height function,carveRoadsbakes the cut into the DEM at load so tile splits cannot reveal sub-quad road humps, and painting asphalt into the splat turned a distant road from sub-pixel geometry into mip-filtered texture (island frame-diff 1.4 → 0.05–0.13 /255 at the MSAA2 default). Spans are classified from NVDB point heights before conforming, so bridges get decks and tunnels and ferry legs are excluded rather than submerged.threepp::Oceanis a first-class object type:scene->add(Ocean::create())gives a three-cascade Phillips-spectrum sea with rectangular extents, decoupled mesh and FFT resolution, live wind, and aLookenum pinning ocean versus pond material recipes. The header is explicit that the surface animates only under Vulkan; on GL it is an inert flat plane wearing the water material. Compiled only whenTHREEPP_WITH_VULKAN=ON.BranchingMode::Whorlgives monopodial conifers with drooping branch rings instead of pancake stacks; the leaf atlas is now a branchlet of ~30 leaflets with per-species blade outlines, so canopy density comes from the texture rather than from stacking giant quads;BarkStylecovers furrowed, plated and papery with birch lenticels. A voxel field over leaf-bearing nodes bakes sky-occlusion into vertex colour so canopies shade as a volume with a dark core. Card winding was made to agree with the shading normal, cutting grey view-dependent canopy patches from 8.7% to 1.7% of canopy pixels. Defaults are unchanged — the conifer look is opt-in via preset or by setting the modes directly.GrassTilessplits a field into world-space tiles so the Vulkan renderer can cull off-screen tiles and freeze wind on far ones, with sway seamless across boundaries because phase derives from world XZ. The tapered blade template existed three times with drifted colour constants and now routes through one function, verified as a pixel-identical GL capture.GrassField's shader also now applies tonemap and output encoding before fog, so blades stop rendering near-black on GL.architecture::createLogCabinemits a complete Scandinavian log cabin as ~10 merged geometries with every texture generated in-engine: courses alternate direction so crossing logs overlap by half a diameter, openings snap to course joints and are subtracted from the runs, gable walls stack under the roof plane, and the porch roof springs from the main eave as a broken pitch. Wall dormer, covered porch, divided-light windows, panelled door, rubble foundation and flue are parametric.TerrainScatterplaces camera-following ground-cover tufts through the same provider the tiles bake from, so props match their ground and painted roads are excluded automatically; cells build deterministically from hashed coordinates. The radius is deliberately short (55 m) because centimetre blades are sub-pixel past ~60 m and sparkle under jitter. A stone species was built and then removed — a stone on the provider surface floats or sinks wherever the rendered tile mesh deviates.TerrainGenerator's baked AO was a single-cell Laplacian, i.e. a band-pass that stippled noise bumps into dark dots and rang at slope breaks. It now takes the Laplacian at 1 and 2 cells and keeps the weaker magnitude only when both agree the sample is concave, with five new knobs. The scale ofaoStrengthchanged as a result.Conveyors, vehicles and examples
threepp::conveyoris a first-party subsystem: waypoint paths with exact circular-arc corner fillets whose radius self-clamps to what the neighbouring segments allow, per-segment flat/roller/cleat surfaces, watertight belt and wall ribbons with arc-length UVs, roller and cleat layout, a support frame derived from belt width, and a procedural scrolling belt texture. Compiled into the library unconditionally.speed / radius, so every contact reads the true rolling surface velocity. Requires the PhysX SDK.Vehiclehandle. Automatic transmission and four wheels only — the runtime is a fixed array of four.fps_demo(procedural arena with real colliders, animated viewmodel, flow-field enemies that hand their skeleton to physics on death, pooled casings/decals/rigs — pooling took a 600-frame firing run from four ~240 ms spikes to a 10.3 ms max),Spot(a walking policy in pure C++ with a std-only MLP loader, both the weights and a golden reference committed so it runs from a fresh checkout; parity to 2.9e-06 over 64 golden pairs, walking 8.04 m at a commanded 1.0 m/s), andNorwayDrive(a PhysX car on Kartverket/NVDB geodata, driving a re-cooked terrain window that already is the road surface because roads are carved into the DEM at load).RobotCellmoved to a Franka FR3 with a real two-finger gripper — none of the four robots the repo shipped previously had an actuated end effector. Two defects surfaced: articulation drive-target order is topological whileRobot's DOF order is document order, sosetDriveTargetssilently drove the wrong axis (now mapped by name), and the motion planner'sguard()never actually arced around the base. Honest caveat stated in the source:tryGrab()is still a distance test that flips the crate kinematic, so a fully open gripper would still carry.terrain::makeRockGeometry(existed three times, and only two copies carried the pole-flicker fix, so the artefact was live in the third),audio::synthprimitives, and a shared Mustang rig. Eight hand-rolledsmoothstepcopies collapsed ontomath::smoothstep.Build, CI and dependencies
CMakePresets.jsonships 7 user presets (gl,gl-debug,vulkan,vulkan-aaa,python,no-glfw,wasm) plus 9ci-*ones, each building intobuild/<preset-name>. CI runs the same presets, so the CI configuration and the documented one are one object.CMakeUserPresets.jsonis gitignored for machine-local presets.linux-asan(ASan + UBSan, leak detection deliberately off because the GL driver leaks per-process allocations) andpython-bindings(3.10/3.12 matrix, pytest under Xvfb on software GL). The main Linux job now usesci-linux-physx, which adds the vcpkg toolchain and thephysxfeature — unlocking 14 test executables and 8 example directories that had never been compiled in CI. vcpkg is pinned with its binary cache persisted, and a guard step grepsctest -Nfor a known PhysX test so a silent fall-back to the "physx not found" branch cannot leave the job green. GPU paths remain untested — the runners have no device.pages.ymlpublishes the WASM examples to GitHub Pages on every push to master, andwheels.ymlbuilds release wheels and an sdist and publishes them to PyPI on a date tag, with TestPyPI rehearsals onworkflow_dispatch(see the Python section).THREEPP_BUILD_EXAMPLESandTHREEPP_BUILD_TESTSnow default toPROJECT_IS_TOP_LEVELrather than ON, so a FetchContent consumer no longer pulls in 116 executables, Catch2 and the threepp_data asset repo. This is what movedcmake_minimum_requiredfrom 3.19 to 3.21.embed.cmakeread and wrote shader headers at configure time, so nothing in the build graph depended on shader sources — editing one and building rebuilt nothing, and the binary kept running whatever was embedded when CMake last configured. Each shader now has anadd_custom_commanddepending on its.glsl. The Vulkan shader step likewise tracks sibling.glsl/.hincludes, anddeferred_shade.compwas split into topical includes with the SPIR-V verified bit-identical.stbi_write_png_to_funclived only insideGLRenderer.cpp, draggingCanvas::initWindowinto a test that touches no renderer and breaking the no-GLFW job;BC7Encode.cppsat inside the Vulkan source block while its test was registered unconditionally; andcmake/PhysxStaticLinkFix.cmakewraps the static PhysX archives in a$<LINK_GROUP:RESCAN,...>because single-pass GNU ld cannot resolvePhysXExtensions' backward references, and appendslibPhysXVehicle2_static_64.a, which the vcpkg port installs on Linux but only links on Windows.SKIP_RETURN_CODE 42so they self-skip without a GPU; golden references are GPU-specific and documented as a local pre-push check, not a cross-hardware gate..gitattributesmarks*.ppmbinary aftercore.autocrlfrewrote a golden's header LFs and rotated its colour channels.KHR_meshopt_compression). libwebp 1.6.0's decoder subset is vendored and compiled in unconditionally, pinned at ≥1.3.2 because CVE-2023-4863 is reachable from an ordinary.webp.threepp_datamoved fromGIT_TAG mainto a pinned commit, so example and test assets are reproducible.CMAKE_BUILD_TYPEon a single-config generator now defaults to Release — deliberately only when top-level, since a consumer owns its own build type.Removed
WgpuRenderer,CrossRenderer, 30 files underrenderers/wgpu/,GraphicsAPI::WebGPUand::Cross, theTHREEPP_WITH_WGPU/THREEPP_WGPU_GLSL_COMPAToptions and their fetch modules,imgui_impl_wgpu,examples/wgpu/, the WebTide example project anddoc/wgpu-feature-parity.md— a net −32,332 lines. No CI job ever built it, and the largest test in the suite (3,281 lines) exercised a backend that never compiled. Salvaged rather than deleted: 29 GL and analytic furnace test cases that only ever built underTHREEPP_WITH_WGPUnow run by default.VulkanRendererCore.hppsurvives as a four-line shim.setTaaHdrInputwas likewise added and removed.RibbonGeometry,RoadPath,RoadAlignment,RoadGeometry, theRoadspline mesh kind and the road hull collider are gone.SplineConfig::MeshKindis{None, Tube}. Kept from that work: the Tube geometry, the physics-collider overlay,Shape::Autoresolving to a triangle mesh or hull, and--screenshot. None of it was ever on master.examples/libs/kine(Kine, KineLink, KineLimit, CCD/DLS IK solvers,Angle.hpp) and theCrane3Rexample, superseded byRobot+extras/kinematics/InverseKinematics.hpp;urdf_loader_objandurdf_loader_simple, folded intourdf_loader; the Python spider and turret RL demos.MaterialWithSheen::sheen(the legacy r129 optional colour), the masked-FXAA overlay path,bloom_blur.compandcomposite.comp,src/threepp/loaders/XacroProcessor.hpp,EventCameraSensor's wall-clock epoch, the per-vehicle duplicated PxVehicle2 stacks, andTerrainScatter's stone species.THREEPP_WITH_INFERENCEsurvives (Vulkan-only now) but its tests were deleted with the WGPU backend, so the feature is example-only with no automated coverage.Breaking changes
Backends and build
GraphicsAPI::WebGPUandGraphicsAPI::Crossare gone from the Canvas/RendererFactory enum;WgpuRenderer.hpp,CrossRenderer.hppand all ofrenderers/wgpu/(including the oldwgpu/EffectComposer.hpp— the replacement isthreepp/postprocessing/) are deleted.THREEPP_WITH_WGPU,THREEPP_WGPU_GLSL_COMPATandTHREEPP_FETCH_ASSIMPremoved;THREEPP_WITH_INFERENCEnow depends onTHREEPP_WITH_VULKANalone.cmake_minimum_requiredis 3.21.THREEPP_BUILD_EXAMPLESandTHREEPP_BUILD_TESTSdefault toPROJECT_IS_TOP_LEVEL— a subproject consumer that relied on the old ON default now gets only the library.install(TARGETS glslang SPIRV glslang-default-resource-limits)dropped from the export set.Vulkan renderer
setRenderMode,setSamplesPerPixel,setMaxBounces,setRestirGIEnabled,setRestirDIVisibilityReuse,setSerEnabled,setPerSppJitterHybrid,setMeasurePrimaryTraceOnly,setSilhouetteMsaaExtra(superseded bysetGbufferMsaa).setVolumetricFog(bool)is a no-op kept only so callers compile — shafts and aerial perspective are always on when a fog medium exists, so passingfalseno longer disables them.setDeferredDenoise/deferredDenoiseare deprecated aliases.VulkanRendererCoreis now an alias ofVulkanRenderer, not an abstract base — source-compatible for calls anddynamic_cast, but a type that inherited from or forward-declared it as a distinct class will not compile.Core and OpenGL
Object3D::renderOrderchanged fromunsigned inttoint(as didRenderItem::renderOrder/groupOrderand theRenderListpush/unshift chain).Object3D::removeFromParent()returnsstd::shared_ptr<Object3D>instead ofvoid. Source-compatible for callers that ignore it — but discarding the result still destroys a solely-owned child.Object3Dnow publicly derivesstd::enable_shared_from_this<Object3D>. Do not callshared_from_this()on a stack-allocated object.BufferAttributegained four pure virtuals (type(),data(),byteLength(),cloneUntyped()); any subclass not deriving fromTypedBufferAttribute<T>no longer compiles.MaterialWithSheen::sheenremoved — usesheenColor/sheenRoughness.MaterialWithReflectivitynow inheritsMaterialWithRefractionRatiovirtually, so every concrete material deriving from it must name that base in its own init-list. (With two copies,material->refractionRatiowas ambiguous, thedynamic_castreturned null so serialization skipped the field, and the renderer read a different copy thansetValues()wrote.)BufferGeometry::uuidandObject3D::uuidare no longerconst;ProgramParameters::sheenis nowboolandProgramParameters::from()gained a trailingColorSpace outputColorSpace;Renderergained a virtualgetCurrentViewport(Vector4&) const;EventDispatcher::hasEventListeneris nowconstand the class gained user-declared copy/move operations;TorusKnotGeometry's protected constructor takes aconst Params&.KeyframeTrack's constructor throwsstd::invalid_argumenton empty times or a value count that is not a positive multiple of the time count — previously accepted, with the divide-by-zero deferred to playback.Texture::image() constthrows on an empty image vector instead of callingfront().Scene,PerspectiveCameraandOrthographicCamerareturn their three.js names fromtype()instead of inheriting"Object3D".ImageDatagained astd::vector<uint16_t>alternative — an exhaustivestd::visitover it no longer compiles until the new case is handled.GLTFLoader::preserveNarrowAttributesdefaults true, so normalized uint8/uint16COLOR_0loads narrow;getAttribute<float>("color")returns null on such meshes. Read throughFloatAttributeViewor set the flag false.KHR_materials_sheenorKHR_materials_specularrender differently, andF_Schlickswitched to exact pow5 for every material (peak ~0.003 absolute).PhysX
PhysxWorld::Settingsgained fields in the middle of the struct. Designated initializers are fine; positional aggregate init now assigns to different members.PhysxVehicle::Settings/PhysxVehicleEngineDrive::Settingsare aliases of types deriving fromPhysxVehicleBaseSettings— the names still resolve, but designated initialization of an inherited member is ill-formed for a derived aggregate and will not compile.onPreSubstep/onPostSubstepreturn aSubstepHandleinstead ofvoid. Ignoring it still compiles, but anything registering a callback capturingthismust now unregister in its destructor.removeActornulls slots insideInstancedMeshbindings, so an instance whose actor was removed freezes rather than following a neighbouring body. Soft-body geometry that previously took the direct-mapping fast path by coincidence now takes the correct skinned path, changing its rendered rest pose.Robotics
Robot's default end effector changed from "the last declared joint" to "the deepest leaf link", andcomputeEndEffectorTransformnow walks only the root-to-tip path. Any branched robot gets a different — and correct — answer; callsetEndEffector(linkName)to be explicit.Robot::setJointValueon a prismatic joint rotates the slide axis by the joint's own orientation, not the robot's euler rotation.<collision><cylinder>is treated as Z-aligned per spec, and<collision><mesh>cooks to a convex hull rather than a bounding box. A URDF hand-authored against the old Y-aligned behaviour must be re-authored — the repo's own Spot description was.src/threepp/loaders/XacroProcessor.hppwas deleted; move toinclude/threepp/loaders/Xacro.hpp, whoseProcessoris default-constructed then configured withsetArgs/addPackagePath.Python
Blending.None→Blending.NoBlending(the old name is a Python syntax error and made the whole type stub unparseable);damp(..., lambda=)→damp(..., lambda_=); the flatthreepp.pyiis replaced by a stub package. None of these can break a released API — the module is new in this range.Editor, player, conveyors, splats
apps/,threepp/extras/editor/,threepp/extras/conveyor/and the splat headers did not exist at the previous release, so everything there is additive. Conveyor authoring did migrate from arc-centre waypoints to derived corner fillets mid-cycle, which only affects anyone trackingdev.Getting started note:
threepp/threepp.hppwas not touched in this range, so none of the new subsystems — post-processing,SplatCloud,Ocean, sensors, kinematics, conveyors,AttributeView,InstancedBufferGeometry— is reachable through the umbrella header. Include them explicitly.This discussion was created from the release 2026-08-08.
All reactions