Migrating from 2.x to 3.x
What broke between Meep 2 and Meep 3 - the Shade renderer, WebGPU-only, no three.js - with the full import map, the new constructor shapes, and a worked port of a real demo.
Meep 3 replaced the renderer. Everything else in the engine survived the transition close to unchanged; the graphics half did not. This page is the practical map: what moved, what changed shape, what is simply gone, and how to work through a file with the answers in front of you.
Verified against 3.21.0. Every specifier below is prefixed @woosh/meep-engine/ - the package’s exports map is "./src/*": "./src/*", so the path in the table is the path on disk.
The four changes that cause everything else
1. The renderer is Shade, and it ships in the package. Meep 2 drew through three.js. Meep 3 draws through Shade, meep’s own renderer, which lives in-tree under src/shade/. It is GPU-driven and visibility-buffer deferred: meshlet-clustered geometry sits GPU-resident in one buffer, culling runs on the GPU, and the rasterizer writes a visibility buffer and a G-buffer instead of shading as it draws. Lights are binned into froxels and applied deferred, then the frame is temporally resolved, tonemapped and presented. Nothing about “Forward+” applies any more. See rendering overview.
2. three.js is gone. Not optional, not a peer, not present. No module under src/ imports it. Any code you have that constructs a THREE.* type, subclasses one, or reaches through a meep component to a .object is dead. three should come out of your package.json at the same time you bump the engine.
3. WebGPU only. There is no WebGL path and no fallback. Below the device floor, startup fails cleanly rather than degrading. The floor: adapter features indirect-first-instance and float32-blendable, maxStorageBuffersPerShaderStage >= 10, and 32 bytes of colour-attachment budget per sample. A missing navigator.gpu throws a plain Error; anything below the floor throws ShadeDeviceFailure - catch both. A device lost later is not recovered. In practice the browser floor is Chromium 149 or newer, because Shade’s shaders use the WGSL immediate_address_space extension that shipped there.
4. The rendering ECS systems moved to src/engine/graphics3/ and every constructor changed. Meep 2’s uniform new XSystem(engine) is gone; the new shapes take explicit collaborators, usually (graphics, scene).
Everything outside rendering is close to a no-op upgrade. Physics, navigation, AI and behaviour trees, generation, math, ECS core, the DOM view system, networking, plugins and options, storage, logging and localization are unchanged or near enough. The two exceptions are audio and input.
Order of work
- Bump
@woosh/meep-engineto3.xand deletethreefrompackage.json. Node’s floor is>= 24. - Rewrite the graphics system registrations - new paths, new constructors, and the scene argument.
- Wire the scene once, deliberately. See the black-frame trap.
- Replace three.js geometry, material, colour and math types with their Shade equivalents.
- Rename
EntityManager.simulatetoupdate. - If you deserialize old saves that carry sound: call
convertLegacySoundComponents. - Delete what has no replacement, and decide what to do about each hole.
Import map
Named (N) vs default (D) export is called out because several flipped in 3.x - a default import from a module that now exports a name fails at build time if you are lucky and at runtime if you are not.
Rendering ECS systems
| 2.x | 3.x | export | 3.x constructor |
|---|---|---|---|
src/engine/graphics/GraphicsEngine.js (N) | src/engine/graphics3/GraphicsEngine.js | N | () - built by Engine, started from Engine.start() |
src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js (N) | src/engine/graphics3/ShadedGeometrySystem.js | N | (graphics, scene) |
src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.js (N) | src/engine/graphics3/MeshSystem.js | N | (graphics, scene, load) |
src/engine/graphics/ecs/mesh/MeshSystem.js (N, the v1 system) | src/engine/graphics3/MeshSystem.js | N | as above - and the v1 Mesh component is gone, use SGMesh |
src/engine/graphics/ecs/light/LightSystem.js (D) | src/engine/graphics3/LightSystem.js | N | (graphics, scene) |
src/engine/graphics/ecs/camera/CameraSystem.js (N) | src/engine/graphics3/CameraSystem.js | N | (graphics) |
src/engine/ecs/terrain/ecs/TerrainSystem.js (D) | src/engine/graphics3/TerrainSystem.js | N | (graphics, scene, assetManager) |
src/engine/graphics/ecs/water/WaterSystem.js (D) | src/engine/graphics3/WaterSystem.js | N | (graphics) |
src/engine/graphics/ecs/decal/v2/FPDecalSystem.js (N) | src/engine/graphics3/DecalSystem.js | N | (graphics, assets) |
src/engine/graphics/particles/ecs/ParticleEmitterSystem.js (N) | src/engine/graphics3/ParticleEmitterSystem.js | N | (graphics, assets) |
src/engine/graphics/ecs/trail3d/Trail3DSystem.js (D) | src/engine/graphics3/Trail3DSystem.js | N | (graphics) |
src/engine/graphics/ecs/path/PathDisplaySystem.js (N) | src/engine/graphics3/PathDisplaySystem.js | N | (graphics) |
graphics/ecs/highlight/system/: MeshHighlightSystem (D), RenderableHighlightSystem, HighlightSystemBase | src/engine/graphics3/HighlightOutlineSystem.js (the pass) + .../mesh-v2/aggregate/SGMeshHighlightSystem.js (the model source) | N | (graphics) and (outline, meshes) |
graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js | same path | N | (outline), dependencies = [Highlight, ShadedGeometry] |
src/engine/ecs/fow/FogOfWarSystem.js (N) | src/engine/graphics3/FogOfWarSystem.js | N | (graphics) |
src/engine/graphics/render/gizmo/GizmoRenderingPlugin.js | src/engine/graphics3/DebugDrawSystem.js | N | (graphics) - the Gizmo component keeps its path |
src/engine/ecs/systems/AnimationSystem.js (D) | src/engine/graphics3/AnimationSystem.js | N | (graphics, meshes) |
src/engine/graphics/ecs/animation/animator/AnimationGraphSystem.js (N) | src/engine/graphics3/AnimationGraphSystem.js | N | (graphics, meshes) |
src/engine/ecs/tooltip/TooltipComponentSystem.js (N) | src/engine/graphics3/TooltipComponentSystem.js | N | ({graphics, tooltips, pointer, localization, picking}) |
| - | src/engine/graphics3/PickingSystem.js | N (new) | (meshes) |
| - | src/engine/graphics3/VolumetricLightMapSystem.js | N (new) | (graphics, scene) |
| - | src/engine/graphics3/ParticipatingMediaSystem.js | N (new) | (graphics, scene) |
Two traps in that table worth stating on their own. src/engine/ecs/terrain/ecs/TerrainSystem.js still exists in 3.x as a default export - it just renders nothing, so an unchanged import compiles and silently draws no terrain. And outlining is now a pass plus a source system per kind of highlighted thing: graphics3/HighlightSystem.js still resolves (it re-exports SGMeshHighlightSystem) but its constructor changed, so new HighlightSystem(graphics, meshes) has to become new HighlightOutlineSystem(graphics) plus new SGMeshHighlightSystem(outline, meshes) - and new ShadedGeometryHighlightSystem(outline) if you outline primitives.
Renderer plumbing that was replaced
| 2.x | 3.x |
|---|---|
graphics/render/forward_plus/plugin/ForwardPlusRenderingPlugin.js | Gone. Froxel binning is internal; there is nothing to register or size. |
graphics/StandardFrameBuffers.js, graphics.frameBuffers | Gone. Typed frame records inside RenderExtension.record(frame). See render extensions. |
graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js | Gone. GTAO is always present; renderer.feature_ssao_enabled defaults to true. Delete the import and its config.addPlugin(...) line. |
graphics/render/visibility/hiz/buffer/HierarchicalZBuffer.js, hiz/query/BatchOcclusionQuery.js | The HZB moved inside Shade (src/shade/renderer/hiz/) and is not something you drive; the batch occlusion query has no replacement. |
graphics/trail/x/** (RibbonX, RibbonXPlugin, RibbonMaterialX) | Gone. Use Trail3D, or make_gradient_stroke on the dynamic-mesh path. |
graphics/geometry/optimization/merge/merge_geometry_hierarchy.js | Gone, no replacement. GPU culling makes CPU merging pointless. |
graphics/geometry/MikkT/**, buffer_geometry_ensure_tangents | Geometry#ensureTangents() / Geometry#computeTangents() |
graphics/texture/virtual/VirtualTextureSystem.js and friends | Per-material opt-in: StandardShadeMaterial.vt_stack = VirtualTextureStack.from({...}). See virtual texturing. |
graphics/sh3/lpg/**, sh3/lpv/**, sh3/lightmap/**, sh3/gi/** | All gone. GI is now renderer.indirect_lighting_mode plus the VolumetricLightMap component. See global illumination. |
graphics/sh3/sky/hosek/make_environment_sky_hosek.js | Gone as glue. The Hosek CPU sky itself survives; see sky and environment for the conversion recipe. |
graphics/context/WebGLContextMonitor.js, engine.graphics.context | Gone. engine.graphics.on.{contextLost,contextFailed} carry a ShadeDeviceFailure; contextRestored never fires. |
view/graphics/WebGLContextFailureView.js (N) | src/view/graphics/GraphicsContextFailureView.js (N) - and Engine now installs it for you, so remove your own subscription or you get two overlays. |
graphics/camera/CameraShake*.js | Unchanged, same paths. |
Assets and models
| 2.x | 3.x |
|---|---|
engine/asset/loaders/GLTFAssetLoader.js (N) | engine/asset/loaders/GLTFSceneBundleAssetLoader.js (N) - yields a SceneBundle, and create() returns the same shared instance every call |
engine/asset/loaders/texture/TextureAssetLoader.js, loaders/material/** | Gone. DecalSystem and ParticleEmitterSystem register an ImageRGBADataLoader for GameAssetType.Image themselves. GameAssetType.Texture and DeferredTexture remain as strings, but no loader produces them. |
| - | engine/asset/loaders/image/ImageBitmapAssetLoader.js (N, new). load_gltf asks the asset manager for a model’s images, so without this registered against GameAssetType.ImageBitmap you load geometry with no textures. |
| - | engine/asset/load_model_scene_bundle.js -> load_model_scene_bundle(assetManager, url) (N) |
| - | engine/graphics3/instantiate_scene_bundle.js -> instantiate_scene_bundle, SceneBundleInstance (N) |
GameAssetType.ModelThreeJs, GameAssetType.AttachmentSockets | Removed from the enum. |
engine/save/GameStateLoader.js | Gone, no replacement. See persistence for the serializer flow. |
The loader registration that most ports need:
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { GLTFSceneBundleAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFSceneBundleAssetLoader.js";
import { ImageBitmapAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageBitmapAssetLoader.js";
const gltf = new GLTFSceneBundleAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltf);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltf);
config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
ECS, hierarchy, animation
| 2.x | 3.x |
|---|---|
EntityManager#simulate(dt) | EntityManager#update(dt) |
engine/ecs/transform/Transform.js - the pose component | engine/ecs/transform/Transform64.js. Every system’s dependency tuple names it; Transform still ships and is still read by the serialization registry, but nothing depends on it. position.set(x,y,z) becomes setTranslation(x,y,z), rotation.set(...) becomes setRotation(...) plus updateMatrix(), position.x becomes translation_x, and fromJSON keys the translation translation rather than position. rotation.fromAxisAngle(axis, angle) has no member to call - the getter hands back a bare Float64Array - so it becomes t64_set_rotation_axis_angle(t, ax, ay, az, angle) (engine/ecs/transform/t64_set_rotation_axis_angle.js), whose axis must be unit length. A Transform64 has no signals, so a write ends with t64_announce_change(ecd, entity). See the ECS overview. |
engine/ecs/parent/ChildEntities.js | engine/ecs/hierarchy/EntityChildIndex.js (N) - derived, never serialized |
engine/ecs/attachment/**, engine/ecs/sockets/** | engine/ecs/transform-attachment/{TransformAttachment,TransformAttachmentSystem}.js (N) |
bone lookup by HumanoidBoneType | transform_attachment_find_descendant_by_name(dataset, root, name); world pose via engine/graphics3/pose/query_entity_node_world_pose.js. BoneMapping and HumanoidBoneType still exist but nothing reads them. |
graphics/ecs/animation/animator/graph/AnimationGraph.js | engine/graphics3/animation/AnimationGraphController.js (N) - it is the component. See animation graphs. |
engine/ecs/animation/AnimationOptimizer.js | ShadeAnimationClip#optimize() |
engine/ecs/transform/copy_three_transform.js | t64_copy_from_transform.js, transform_copy_from_t64.js (N) |
engine/ecs/renderable/{RenderSystem,Renderable}.js | Gone (both were already deprecated in 2.x). |
engine/ecs/systems/TimerSystem.js, engine/ecs/components/Timer.js | Gone, no replacement. |
Colour and geometry helpers
core/color/ renamed its name2name modules to name_to_name (hex2rgb.js -> hex_to_rgb.js, rgb2hsv.js -> rgb_to_hsv.js, and so on). The old paths still ship as deprecated re-exports, so nothing breaks on the bump - but one pair changed meaning rather than spelling:
| 2.x | 3.x | note |
|---|---|---|
hsv2rgb (0..255) | hsv_to_rgb_uint8 | the byte variant kept its behaviour, not its name |
hsv2rgb_float (0..1) | hsv_to_rgb | the unsuffixed name now means 0..1 |
The object half-edge mesh is gone: TopoMesh, TopoVertex, TopoEdge, TopoTriangle and the whole tm_* family (~91 modules) were replaced by the flat binary topology under core/geom/3d/topology/struct/binary/ with a bt_* prefix. The mapping is mechanical - query_edge_is_manifold becomes bt_edge_is_manifold, computeTopoMeshBoundingSphere becomes bt_mesh_compute_bounding_sphere. Quadric3 moved to core/geom/3d/quadric/. See geometry.
The constructor-shape change
In 2.x every graphics system took the engine and dug what it needed out of it:
// 2.x
config.addSystem(new ShadedGeometrySystem(engine));
config.addSystem(new LightSystem(engine));
In 3.x each takes exactly the collaborators it uses, and the Shade Scene is one of them:
// 3.x
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { LightSystem } from "@woosh/meep-engine/src/engine/graphics3/LightSystem.js";
const scene = EngineHarness.shadeScene(engine);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
config.addSystem(new LightSystem(engine.graphics, scene));
engine.graphics is the GraphicsEngine facade; it is null on an engine configured without graphics, which is what makes headless work possible. EngineHarness.shadeScene(engine) hands out the one Shade Scene per engine, memoized in a WeakMap - call it as often as you like, you get the same scene, which is the point.
The scene-wiring rule and the black-frame trap
GraphicsEngine constructs its own empty Scene and renders that until it is told otherwise. set_scene(scene) is what tells it. Only three systems call set_scene for you: LightSystem, MeshSystem and ParticipatingMediaSystem.
So an app that registers, say, ShadedGeometrySystem alone puts its meshes in the harness scene while the renderer is still drawing the facade’s empty one. Nothing throws. You get a black frame.
engine.graphics.set_scene(EngineHarness.shadeScene(engine));
Calling it when a system would have done it anyway is harmless - it is an assignment. Two related facts, both from set_scene itself: a scene that arrives without an environment map gets make_default_environment() installed, because a scene with no environment renders unlit; and a scene that arrives with one keeps it.
simulate becomes update
// 2.x
engine.entityManager.simulate(dt);
// 3.x
engine.entityManager.update(dt);
simulate survives as a deprecated alias assigned onto the prototype, so old code keeps running. Rename anyway - and note that the class’s own JSDoc example still shows simulate, so don’t take that as a signal.
The deprecated *3 aliases
Every module in src/engine/graphics3/ has a sibling with a 3 suffix - MeshSystem3.js next to MeshSystem.js, twenty of them in total - each a one-line deprecated re-export:
export const MeshSystem3 = MeshSystem;
The suffix marked the 2.x-to-3.x transition and disambiguated against a 2.x name that no longer exists. They are kept because an import path is published interface, not because they are the name to use. Always import the unsuffixed name. If you have code written against an early 3.x preview that uses the suffixed names, dropping the 3 is a safe mechanical rename.
Deleted with no replacement
These are the holes. Each one is a decision you have to make, not an import you can rewrite.
| Gone | What to do |
|---|---|
Impostors and LOD - every baker and shader under graphics/impostors/** | There is no impostor system and no LOD system at all in Meep 3. Meshlet clustering and GPU culling carry the load instead. Ten orphaned data and UV-encoder modules remain in the tree with no importers; ignore them. |
SH3 light probe grids and volumes, lightmap baking - graphics/sh3/lpg/**, sh3/lpv/**, sh3/lightmap/** | Rebuild on renderer.indirect_lighting_mode - IBL, Brick4 or LPV. See global illumination. |
Trail2DSystem | The Trail2D component still ships, JSON round-trip intact; the system that drew it does not exist, so it renders nothing at all. Move to Trail3D, or draw the beam yourself on the dynamic-mesh path. |
RibbonX and its plugin, material and mesh | Trail3D plus make_gradient_stroke. |
| The old virtual texture system | Per-material vt_stack, opaque materials only. The 2.x tiled converter output still reads, via new VTSourceTiled({ layers, legacy_meep_mips: true }). |
MeshPreview / MeshView widgets | No widget replacement. engine/graphics3/preview/make_model_thumbnail returns pixels and is tooling-only. |
RenderSystem / Renderable | Already deprecated in 2.x. Use ShadedGeometry or SGMesh. |
AnimationOptimizer | ShadeAnimationClip#optimize(). |
CapsuleGeometry | No visual capsule. Build one from make_cylinder_geometry plus two hemispheres, or use a different shape. The physics CapsuleShape3D is unaffected. |
TextureAssetLoader and the material loaders | Delete the registration. Register ImageRGBADataLoader for GameAssetType.Image and ImageBitmapAssetLoader for GameAssetType.ImageBitmap instead. |
GameStateLoader | No replacement. Use BinaryBufferSerializer / BinaryBufferDeSerializer directly. |
The TopoMesh family | The bt_* binary topology, as above. |
The node-based particle GLSL codegen - graphics/particles/node-based/** | No replacement you can use. src/shade/renderer/particles/ is a bytecode VM and is not wired into the renderer; do not build on it. The CPU particle simulation itself is unchanged. |
TopDownCameraControllerHelper | No replacement. |
merge_geometry_hierarchy, BatchOcclusionQuery, TimerSystem | No replacement; all three solved problems the new renderer does not have. |
Audio: run the legacy conversion
The legacy sound stack moved to src/engine/sound/sopra/legacy/ under the same filenames and was demoted to data only. SoundEmitter, SoundTrack and SoundController still deserialize; nothing plays them. SoundEmitterSystem and SoundControllerSystem are deleted, and there is no system of those names to swap in - the replacements are AudioEmitterSystem (src/engine/sound/ecs/audio/) and AudioEventTriggerSystem (src/engine/sound/ecs/trigger/).
Old scenes and saves keep loading, but what they load is inert until you convert it. That pass is deliberately not run by the deserializer - a host owns its load pipeline - so you call it yourself, after deserialization:
import { convertLegacySoundComponents } from "@woosh/meep-engine/src/engine/sound/sopra/legacy/convertLegacySoundComponents.js";
import { SopraDefaultBus } from "@woosh/meep-engine/src/engine/sound/sopra/SopraEngine.js";
const { emitters, triggers, discarded } = convertLegacySoundComponents(dataset, {
knownBusIds: Object.values(SopraDefaultBus),
});
It mutates the dataset in place, turning SoundEmitter into AudioEmitter and SoundController into AudioEventTrigger, and returns counts of each plus the legacy components it removed with nothing to play.
Pass knownBusIds. A legacy channel is a free string on old authored data, and sopra’s BusGraph.getInput throws on an id it does not have - so without the set, a stale channel name survives conversion and fails at the emitter’s first play. Given the set, an unrecognised channel is logged and rewritten to effects, which is what the 2.x runtime did at play time anyway.
Two things conversion drops on purpose, because neither survived a load in 2.x either: track.time (the legacy runtime always restarted a restored track from zero) and panningModel (the binary adapter never wrote it). See events and mixing.
Input: the deprecations
Input (engine/input/ecs/components/Input.js) and InputSystem (engine/input/ecs/systems/InputSystem.js) still ship and still work, and both are marked @deprecated. The replacement is the Input Map System - InputMap and InputMapSystem under engine/input/ecs/ism/ - which binds typed triggers instead of magic strings, expresses chords and sequences, arbitrates between contexts, and serializes.
One piece of 2.x advice inverted: InputController and InputControllerSystem are not deprecated. They own pointer gesture binding - tap, drag, pinch - which the binding layer does not model, and they remain the only way to get it. If your 2.x notes say “prefer InputSystem over InputControllerSystem”, that is now backwards. See input devices.
TopDownCameraControllerHelper was removed outright.
three.js to Shade
The equivalence table for the types you are most likely to be holding. Shade’s own modules all use named exports.
| three.js | Meep 3 |
|---|---|
WebGLRenderer | Renderer (src/shade/renderer/Renderer.js), normally reached through engine.graphics |
Scene | Scene (src/shade/renderer/scene/Scene.js), obtained via EngineHarness.shadeScene(engine) |
Object3D | Node3D (src/shade/renderer/scene/Node3D.js) |
Mesh | Shade Mesh / SkinnedMesh, or the ECS components ShadedGeometry and SGMesh |
PerspectiveCamera | PerspectiveCamera (src/shade/renderer/camera/), or the ECS Camera component |
OrbitControls | OrbitalCameraController (src/shade/renderer/camera/), or TopDownCameraController in the ECS |
BufferGeometry, Float32BufferAttribute | Geometry and Attribute (src/shade/renderer/geometry/), plus StandardAttributes for the names |
MeshStandardMaterial | StandardShadeMaterial (src/shade/renderer/material/) |
MeshBasicMaterial and the other material classes | No equivalent. StandardShadeMaterial is the only concrete material; there is no unlit material. |
DoubleSide / FrontSide / BackSide | material.draw_side = ShadeDrawSide.Double (.Front, .Back). Double-sided drawing works. |
Color, 0xrrggbb literals | meep’s Color (src/core/color/Color.js) - see the sRGB note below |
Vector3, Quaternion | meep’s Vector3 and Quaternion (src/core/geom/), both default and named exports, both Float64Array subclasses |
Matrix4 | No matrix class. Matrices are 16-element arrays; use the m4_* family in src/core/geom/3d/mat4/ (m4_multiply, m4_look_at, m4_perspective, m4_invert, m4_transpose, …) |
Raycaster | PickingSystem + PickingQuery - async, batched, and against AABBs rather than triangles. See picking. |
CanvasTexture | createImageBitmap(canvas) -> ShadeImage -> ShadeTexture |
GLTFLoader | GLTFSceneBundleAssetLoader, or load_gltf directly |
RGBELoader + PMREMGenerator | load_cube_environment / load_environment_map, then graphics.set_environment_map(texture). No prefilter step - Shade convolves on the GPU. |
AnimationMixer, AnimationAction | AnimationGraphController + GPUAnimationManager |
ArrowHelper, GridHelper, AxesHelper | the Gizmo component + DebugDrawSystem |
EdgesGeometry + LineSegments | No equivalent. Debug lines go through Gizmo; wireframe overlays have to be built as geometry. |
mergeBufferGeometries | No equivalent. Merge by hand into one Geometry, or don’t - GPU culling makes CPU merging much less useful. |
SkeletonUtils | transform_attachment_find_descendant_by_name + query_entity_node_world_pose |
Geometry generators
Shade ships seven primitive generators, all in src/shade/renderer/geometry/primitives/. Two shapes three.js gives you are not among them.
| three.js | Shade |
|---|---|
BoxGeometry(w, h, d) | make_box_geometry(width, height, depth, widthSegments, heightSegments, depthSegments) |
PlaneGeometry(w, h) | make_plane_geometry(width, height, widthSegments, heightSegments) |
SphereGeometry(r, wSeg, hSeg) | No UV sphere. make_octahedron_geometry(radius, detail) - a subdivided octahedron; detail 3 or 4 reads as smooth at typical prop scale. |
CylinderGeometry(rTop, rBottom, h, seg) | make_cylinder_geometry(radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength) |
ConeGeometry(r, h, seg) | make_cylinder_geometry(0, r, h, seg) |
TorusGeometry(...) | make_torus_geometry(radius, tube, radialSegments, tubularSegments, arc) |
TorusKnotGeometry(...) | make_torus_knot_geometry(radius, tube, tubularSegments, radialSegments, p, q) |
PolyhedronGeometry(...) | make_polyhedron_geometry(vertices, indices, radius, detail) |
CapsuleGeometry(...) | No equivalent. |
A generator returns an authoring-form Geometry. The renderer draws the clustered GPU form, so run it through meshlet_geometry_build_from_geometry once per shape - it is a precompute, not a per-frame cost, and the result is shareable across every entity using that shape.
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
const geometry = meshlet_geometry_build_from_geometry(make_box_geometry(1, 1, 1));
Materials
StandardShadeMaterial is the whole material story. Its fields, and what they replace:
| Field | Type | 2.x counterpart |
|---|---|---|
diffuse_color | Color | MeshStandardMaterial.color |
roughness_factor | number | .roughness |
metallic_factor | number | .metalness |
emissive_factor | Color | .emissive |
texture_albedo, texture_normal, texture_orm, texture_emissive | ShadeTexture | the corresponding maps; ORM packs occlusion/roughness/metalness in one texture |
transmission_factor, ior_factor | number | transmission extension |
vt_stack | VirtualTextureStack | the old virtual texture material |
Inherited from ShadeMaterial: name, transparency_mode (TransparencyMode.Opaque / AlphaTested / Transparent), draw_mode, draw_side.
KHR_texture_basisu (KTX2) is read and transcoded and meshopt bufferViews are decoded; Draco is not - see meshes and materials.
The sRGB trap
This one bites everybody, because it fails quietly and only looks slightly wrong.
Color is colour-space agnostic. Color.parse, toHex and toCssRGBAString speak encoded sRGB - the hex a designer types. Everything the renderer does, and everything in core/color/operations/, expects linear. Handing a parsed hex straight to a material makes it visibly too bright:
// wrong - #4ef0a8 as sRGB bytes, interpreted as linear
material.diffuse_color.copy(Color.parse("#4ef0a8"));
// right
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse("#4ef0a8")));
Decode once, at the point where the authored colour enters your code - not at every use.
What is linear and what is not. Decode a hex on its way into any of these:
| Slot | Space |
|---|---|
StandardShadeMaterial.diffuse_color, .emissive_factor | linear |
Light.color | linear |
Decal.color | linear - the decal pass composites into the G-buffer albedo, before anything is lit |
HighlightDefinition.color | linear - the outline pass blends into the colour buffer before tone mapping |
glTF baseColorFactor | already linear, by the glTF spec. The loader assigns it untouched; do not decode it |
| an albedo texture | decoded by the sampler, because the loader marks the image ColorSpace.SRGB |
Your ported scene will look more saturated than it did under 2.x
Same hexes, deeper colours. That is the port working, not a mistake.
meep 2 drove three.js 0.136, which predates three’s colour management, and configured the
renderer with outputEncoding = LinearEncoding. Two conversions were missing, one at each end of
the frame:
new THREE.Color(0x4ef0a8)kept the encoded bytes, and the shader used them as a linear albedo - too bright, and with the channel ratios flattened, which is what reads as washed out.- the tone-mapped frame went to the canvas without the sRGB encode, which darkened it again.
The two omissions pull in opposite directions, so a flat mid-tone came out roughly where it belonged and the pipeline looked plausible. What did not survive is everything in between: ACES saw albedo values two to four times brighter than they should have been, and ACES desaturates as it approaches white.
Shade does both conversions. diffuse_color is linear, and the frame is tone-mapped and then
encoded exactly once, by sRGBTransferOETF. Measured on this site’s highlight demo, whose floor
is an untextured #161c24 under the standard 6 lux sun:
| floor albedo | renders as |
|---|---|
from_sRGB_to_linear(parse("#161c24")) | rgb(23, 29, 37) - the authored hex is rgb(22, 28, 36) |
the #161c24 bytes, undecoded | rgb(90, 109, 131) |
A neutrally lit surface at roughly unit exposure coming back as the hex you typed is what a correct round trip looks like; it is not a promise the renderer makes at every exposure. The useful number is the other one - undecoded, that floor is four times too bright.
If the new look is too much in a given scene, reach for the palette or for exposure, not for the colour space: the hexes in a 2.x project were chosen against a pipeline that lightened and flattened them.
Porting a demo, start to finish
The simple-cube example is the smallest complete port. Here is the whole of it.
package.json - bump the engine, drop three.js:
"dependencies": {
- "@woosh/meep-engine": "2.170.0",
- "three": "0.136.0"
+ "@woosh/meep-engine": "3.21.0"
}
Imports - import * as THREE from "three" goes; ShadedGeometrySystem moves to graphics3; the shape, the material and the colour type arrive from Shade and from core/color:
-import * as THREE from "three";
-
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
-import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
+import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
-import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
+import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
+
+import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
+import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
+import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
+import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
Note that ShadedGeometry - the component - did not move. Components largely kept their paths; it is the systems that relocated.
Registration - the constructor gains its two collaborators:
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
- config.addSystem(new ShadedGeometrySystem(engine));
+ config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(engine)));
},
});
The cube itself - geometry through the meshlet builder, material from Shade, colour decoded from sRGB:
-const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
-const cubeMaterial = new THREE.MeshStandardMaterial({ color: 0x4ef0a8 });
-const cubeMesh = ShadedGeometry.from(cubeGeometry, cubeMaterial);
+const cubeGeometry = meshlet_geometry_build_from_geometry(make_box_geometry(1, 1, 1));
+
+const cubeMaterial = new StandardShadeMaterial();
+cubeMaterial.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse("#4ef0a8")));
+
+const cubeMesh = ShadedGeometry.from(cubeGeometry, cubeMaterial);
The pose - the one ECS change in the file:
-const t = new Transform();
-t.position.set(0, 0, 0);
+const t = new Transform64();
+t.setTranslation(0, 0, 0);
Everything else - EngineHarness.buildBasics, new Entity().add(...).build(ecd), the postRender FPS hook - is untouched. That is the shape of a typical port: the ECS scaffolding survives, the pose component changes name and accessors, the three.js objects at the leaves get swapped, and the system registration line grows two arguments.
The sibling cube-wall example adds the two remaining moves you will hit constantly. Spheres:
-const ballGeometry = new THREE.SphereGeometry(BALL_RADIUS, 28, 20);
+const ballGeometry = meshlet_geometry_build_from_geometry(make_octahedron_geometry(BALL_RADIUS, 4));
and hex colour constants, which become strings and go through one shared factory rather than being decoded at every call site:
function standardMaterial(hexColor, roughness, metalness) {
const material = new StandardShadeMaterial();
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hexColor)));
material.roughness_factor = roughness;
material.metallic_factor = metalness;
return material;
}
Where to go next
- Rendering overview - Shade’s architecture and the
GraphicsEnginefacade. - Render extensions - what replaced the plugin and post-process stack.
- Frame features - the
renderer.feature_*flags and their defaults. - Meshes and materials - the geometry and material surface in depth.
- Architecture - dependencies, the editor, and the test suite.
- Installation - peers, Node floor, and bundler setup.