Forward-rendered 3D¶
Snowpulse's 3D runtime is an additive, opt-in foundation for action RPGs and other small 3D games. It shares the scene graph with the established 2D runtime, but has its own camera projections, mesh/material submissions, depth pass, 3D physics world, and navigation world. The renderer is forward-only.
The supported 3D targets are macOS, Windows, and WebGL 2. iOS and Android must leave all 3D target switches off; their existing 2D runtime remains supported.
Build features¶
The 3D build contract requires CMake 3.22 or newer. All switches below are
target-local arguments to snowpulse_add_game() and default to OFF.
Select features per game so a lean 2D or playable-ad target does not carry 3D libraries or built-in shader payloads:
snowpulse_add_game(my_arpg
SOURCES ${MY_ARPG_SOURCES}
DISPLAY_NAME "My ARPG"
BUNDLE_ID "com.example.myarpg"
ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets"
RENDERING3D ON
PHYSICS3D ON
NAVIGATION3D ON
ASSIMP ON
SPINE_VERSION "4.2"
WEB_MODE GAME
WEB_PLATFORM SNOWBLINK_PLAIN
)
| Target argument | Default | Adds |
|---|---|---|
RENDERING3D |
OFF |
Mesh/material rendering and built-in forward shaders |
PHYSICS3D |
OFF |
Jolt and the 3D physics components |
NAVIGATION3D |
OFF |
Runtime Detour queries; never runtime Recast |
ASSIMP |
OFF |
FBX and glTF/GLB model import; requires RENDERING3D ON |
The matching numeric feature definitions are
SNOWPULSE_HAS_RENDERING3D, SNOWPULSE_HAS_PHYSICS3D,
SNOWPULSE_HAS_NAVIGATION3D, and SNOWPULSE_HAS_ASSIMP. ASSIMP is
independent of procedural meshes, physics, and navigation. NAVIGATION3D
links Detour only. The native snowpulse_nav_baker host tool is the only
target that links Recast.
Box2D and its existing RigidBody and PhysicsCollider components are
independent of Jolt. Enabling or disabling one physics implementation does not
change the other API.
Coordinates and units¶
The 3D convention is right-handed and Y-up:
- +X is right.
- +Y is up.
- -Z is forward.
- The XZ plane is the normal gameplay ground.
- One world unit is one metre.
Legacy 2D scenes retain XY logical/pixel space and their established transform
order. Transform::composeMatrix() remains
T * Rz(-z) * Rx(x) * Ry(y) * S. The quaternion, world-pose, right/up/forward,
and parent-aware world-matrix helpers are computed views of those public Euler
fields; there is no second stored rotation that can drift out of sync.
Quaternion action overloads follow the same rule: Actions::rotateTo(glm::quat,
...) and Actions::rotateBy(glm::quat, ...) interpolate with quaternions, then
write the result back through the node's established Euler transform.
Parent dynamic or kinematic 3D bodies only under transforms that the physics runtime can represent. Non-uniform scale combined with rotation can produce shear and is rejected. Do not put a Box2D body and a Jolt body on the same node.
Camera, projection, and picking¶
Camera::ProjectionMode::Orthographic2D is still the default. It preserves the
existing zoom, shake, pump, and 2D coordinate conversion behavior. A 3D camera
uses Perspective or Orthographic3D:
auto* camera = scene->camera();
camera->projectionMode = snowpulse::Camera::ProjectionMode::Perspective;
camera->verticalFovDegrees = 55.0f;
camera->nearClip = 0.1f;
camera->farClip = 250.0f;
camera->owner()->transform().position = { 8.0f, 12.0f, 16.0f };
camera->owner()->transform().rotation = { -30.0f, 27.0f, 0.0f };
RenderView3D freezes the view, projection, inverse view-projection, camera
position, viewport sizes, and frustum used by one frame. Gameplay normally
uses the matching Scene helpers. Screen inputs are top-left logical pixels,
the same convention as Input::mousePosition():
const snowpulse::Ray3 ray = scene->screenPointToRay(input->mousePosition());
const auto ground = scene->screenPointToPlane(
input->mousePosition(),
snowpulse::Plane3::fromPointNormal({}, { 0.0f, 1.0f, 0.0f }));
const auto labelPosition = scene->projectWorldToScreen(worldPosition);
Ray/plane intersection returns no value when the ray is parallel or when the intersection is behind the ray. World projection also returns no value for invalid views or positions behind the camera.
Models, materials, and animation¶
ModelRenderer accepts a persistent ModelAsset; render backends upload its
immutable vertex/index data and draw each mesh instance with a model matrix.
The renderer supports static and skinned meshes, material-index overrides,
render layer masks, and cast/receive-shadow switches. Animator3D plays named
clips, changes playback speed, loops, crossfades between two clips, uploads a
GPU bone palette, and exposes bone/socket world transforms.
Material3D follows metallic-roughness PBR and also supports unlit shading:
snowpulse::Material3D armor;
armor.baseColorFactor = { 0.12f, 0.28f, 0.5f, 1.0f };
armor.metallicFactor = 0.75f;
armor.roughnessFactor = 0.24f;
armor.alphaMode = snowpulse::MaterialAlphaMode3D::Opaque;
snowpulse::MaterialInstance3D damaged(armor);
damaged.material.baseColorFactor = { 0.35f, 0.08f, 0.05f, 1.0f };
renderer->setMaterialOverride(0, damaged);
Materials have base-color, metallic/roughness, normal, emissive, occlusion, and alpha inputs. Each texture records UV0 or UV1 and linear or sRGB sampling metadata. Base color and emissive are color data; normal, metallic/roughness, occlusion, and masks are linear data. Alpha modes are opaque, mask, and blend. Transparent materials render back-to-front with depth writes disabled; opaque/masked materials render front-to-back with depth writes enabled.
MaterialTexture3D::colorSpace records the material's intent; it does not
change the format of a texture that has already been uploaded. Procedural
materials must request the correct GPU format when loading textures:
snowpulse::AssetManager::TextureLoadOptions colorOptions;
colorOptions.srgb = true;
armor.baseColor.texture = assets->loadTexture("armor-color.png", colorOptions);
armor.baseColor.colorSpace = snowpulse::TextureColorSpace::Srgb;
snowpulse::AssetManager::TextureLoadOptions dataOptions;
dataOptions.srgb = false;
armor.normal.texture = assets->loadTexture("armor-normal.png", dataOptions);
armor.normal.colorSpace = snowpulse::TextureColorSpace::Linear;
The Assimp importer selects those upload options automatically for imported material channels.
AssetManager::loadModel() accepts .fbx, .gltf, and .glb when Assimp is
enabled. Import preserves the node hierarchy and mesh instances, normalizes the
source basis/units to Snowpulse's convention, imports embedded or external
textures, UV1, tangent handedness, skeleton ancestors, bounds, and multiple
named clips. Keep model-relative texture files beside their source model.
loadFbx() and FbxRenderer remain deprecated compatibility wrappers.
When Assimp is disabled, procedural ModelAsset content still renders and a
model-file load returns a clear unsupported-feature error. This is useful for
small Web builds whose shipped assets are already converted to engine-native
data.
Custom mesh shaders are registered with registerShader3D(Shader3DDesc). They
must follow the documented mesh/frame/light/material binding contract used by
the built-in PBR, unlit, skinning, and shadow variants. Supply both desktop
GLSL 3.30 and GLSL ES 3.00 sources for a portable target.
The vertex layout is fixed so uploaded meshes can be shared across built-in and custom shaders:
| Location | GLSL input | ModelVertex value |
|---|---|---|
| 0 | vec3 aPos |
position |
| 1 | vec2 aUV0 |
primary UV |
| 2 | vec4 aColor |
vertex color |
| 3 | vec3 aNormal |
normal |
| 4 | vec4 aTangent |
tangent; w is handedness |
| 5 | uvec4 aBoneIndices |
bone indices |
| 6 | vec4 aBoneWeights |
normalized bone weights |
| 7 | vec2 aUV1 |
secondary UV |
| 8–11 | mat4 aInstanceModel |
built-in-shader instance transform columns |
Declare only the standard uniforms a shader consumes; inactive uniforms are allowed. Snowpulse populates these groups on every draw:
- Object/frame:
u_ViewProj,u_Model,u_Instanced,u_NormalMatrix,u_CameraPosition,u_BoneCount, andu_Bones[60]. The portable built-in skinning palette is capped at 60 bones so it fits WebGL 2's guaranteed vertex-uniform budget alongside the frame and model matrices. Built-in instanced draws setu_Instancedand read the model matrix from locations 8–11; ordinary and custom-shader draws leave it false. - Material factors:
u_BaseColorFactor,u_EmissiveFactor,u_MetallicFactor,u_RoughnessFactor,u_OcclusionStrength,u_NormalScale,u_AlphaCutoff,u_AlphaMode,u_AlphaBlend, andu_Unlit. - Material textures:
u_BaseColorTex,u_NormalTex,u_MetallicTex,u_RoughnessTex,u_EmissiveTex,u_OcclusionTex, andu_AlphaTex, their matchingu_Has*Texflags, the sixu_*UvSetselectors, andu_MetallicChannel,u_RoughnessChannel,u_OcclusionChannel, andu_AlphaChannel. - Lighting:
u_AmbientColor/u_AmbientIntensity, theu_Directional*values,u_PointLightCountplus the fouru_Point*[8]arrays, andu_SpotLightCountplus the sevenu_Spot*[4]arrays. - Sun shadow:
u_ShadowMap,u_LightViewProj,u_ShadowEnabled,u_ReceivesShadows,u_ShadowBias, andu_ShadowNormalBias.
Texture units are stable: base color 0, normal 1, shared glTF
metallic-roughness 2, emissive 3, occlusion 4, alpha 5, and sun shadow 6.
Metallic-roughness uses glTF's B/G channels. A custom vertex shader that
supports skinning should use the identity transform when u_BoneCount == 0.
Set Shader3DDesc::supportsSkinning only when the custom vertex shader consumes
the bone contract; otherwise skinned input is drawn in bind pose. Set
receivesLighting to false for shaders that do not consume Snowpulse's light
contract; the renderer supplies zero light counts/intensity and disables shadow
reception. These flags survive WebGL context restoration.
The built-in renderer combines identical opaque or masked, unskinned
mesh/material submissions into one GPU-instanced draw, including submissions
from separate ModelRenderer nodes that share a ModelAsset. Local lights are
selected against the union of the batch bounds. Transparent, skinned, or custom
shader submissions always remain individual draws. The directional shadow pass
uses the same restriction for its instanced casters.
Forward lights and shadows¶
A scene may contain DirectionalLight3D, PointLight3D, and SpotLight3D
components. Per draw, the forward renderer uses one directional, up to eight
point, and up to four spot lights. Explicit priority wins first; bounds-based
influence breaks ties when the scene exceeds a cap.
One directional light can be the shadow-casting sun. It uses a stabilized
orthographic shadow fit, supports masked and skinned casters, and samples a
3x3 PCF kernel. The default map is 2048 on desktop and 1024 on Web. Configure
shadowDistance, shadowResolution, shadowBias, and shadowNormalBias on
the light. There are no cascades and no shadows from point or spot lights.
The frame order is sun shadow, opaque/masked 3D, transparent 3D, legacy 2D, then pixel-space UI/overlay. Depth is cleared once for the 3D world. A frame with no 3D submissions retains the established 2D render plan and sorting.
For transient diagnostics, RenderQueue::submitDebugLine3D() and
submitDebugBounds3D() enqueue one-frame lines or wire bounds. Leave
depthTest at its default true for world geometry, or pass false for an
always-visible overlay. Debug submissions do not persist between frames.
Jolt physics lifecycle¶
PhysicsWorld3D belongs to one scene and uses gravity (0, -9.81, 0), a 60 Hz
fixed step, bounded catch-up, and render interpolation. Enable it before its
components begin updating. The scene owns the returned pointer, steps the world
after component updates, and prepares interpolated poses immediately before
drawing:
_physics = enablePhysics3D();
// Scene::shutdown, before node teardown:
disablePhysics3D();
_physics = nullptr;
Jolt is linked statically in single precision with C++ RTTI and exceptions disabled. Snowpulse excludes its ObjectStream, profiler, GPU compute, vehicle, soft-body, and upstream sample/tool paths. Desktop uses Jolt's thread-pool job system; Web uses its single-threaded job system.
Attach RigidBody3D and one or more Collider3D components for static,
kinematic, dynamic, or sensor objects. Primitive shapes are box, sphere,
capsule, cylinder, convex hull, and compound arrangements; indexed triangle
meshes are static-only. Bodies expose damping, mass, friction/restitution,
CCD, per-axis position and rotation locks, velocities, force, impulse, and
torque. PhysicsWorld3D provides raycast(), sphereCast(), oriented
boxCast()/capsuleCast(), and sphere, oriented-box, or oriented-capsule
overlaps. Box and capsule queries take a glm::quat orientation. Every query
uses a layer mask and can ignore one node. Collision and trigger callbacks are
queued through stable runtime bindings so destroying a node cannot leave a
queued raw Jolt body reference.
CharacterController3D wraps Jolt CharacterVirtual. Its dimensions are in
metres. Configure capsule radius/height, slope angle, stair height,
stick-to-floor distance, and strength; then call move() with desired planar
world velocity and jump() for vertical motion. Navigation plans a route but
does not write the node transform, so the character controller remains the
single movement authority.
Dynamic bodies drive node world poses. Kinematic nodes drive bodies. Keep static triangle geometry static and split a moving bridge, door, or lift into an appropriate primitive/convex collider.
Detour navigation and authored routes¶
Navigation baking is a native build-time operation. Runtime targets link
Detour only and load the versioned, checksummed .spnav result:
# Add this after a target that enables NAVIGATION3D. It is intentionally not
# created merely by enabling the Detour runtime.
if(NOT EMSCRIPTEN)
snowpulse_add_nav_baker()
endif()
cmake --build cmake-build-debug --target snowpulse_nav_baker
path/to/snowpulse_nav_baker \
--input assets/navigation/level.obj \
--config assets/navigation/level.nav.json \
--output assets/navigation/level.spnav
The OBJ input is normalized right-handed, Y-up triangle geometry in metres.
The versioned JSON sidecar records Recast cell/agent settings, OBJ o/g
names selected by includedNodes, bounded area overrides, authored off-mesh
links, and named route groups. An off-mesh link has a stable numeric id;
route groups may select polygon bounds, link ids, or both. Bake one .spnav
per agent profile. Application assets and deterministic test fixtures must
stay separate. See tools/navbaker/README.md for the complete schema.
{
"schemaVersion": 1,
"agent": {
"height": 1.8,
"radius": 0.34,
"maxClimb": 0.4,
"maxSlope": 45.0
},
"routeGroups": [
{
"name": "crypt_door",
"enabledByDefault": false,
"bounds": { "min": [-1, -0.5, -1], "max": [1, 2.5, 1] }
}
]
}
Load bytes through AssetManager so the same code works with desktop files
and Emscripten's packaged filesystem:
auto bytes = assets->loadBytes("navigation/level.spnav");
snowpulse::NavMeshAsset asset;
std::string error;
if (asset.decode(bytes, &error)) {
navigation.load(asset);
}
NavigationWorld3D provides nearest-point, random-point, full/partial paths,
straight path points, surface raycasts, and distance-to-wall queries.
NavigationAgent3D validates its corridor, advances waypoints, and
replans after the world's revision changes. It deliberately produces planning
state only; feed desiredVelocity() into gameplay steering and
CharacterController3D::move(). NavigationPath3D::pointLinkIdentifiers
preserves off-mesh ids beside straight-path points so game code can recognize
an authored jump, door, or lift transition.
Doors, repaired bridges, jumps, and other authored passages are baked as named route groups. Change navigation, visuals, and collision together in one update:
navigation.setRouteEnabled("crypt_door", open);
doorRenderer->isActive = !open;
doorCollider->isActive = !open;
Changing a route increments the nav revision. Agents whose corridor is stale
replan automatically. Runtime Recast, arbitrary geometry rebakes,
DetourCrowd, DetourTileCache, and local avoidance are not included.
Web constraints¶
- Rendering requires WebGL 2 / GLSL ES 3.00.
- The Web physics job system is single-threaded unless a future deployment explicitly introduces the required cross-origin-isolated thread contract.
- Prefer the 1024 sun-shadow default and compact textures/bone palettes.
- Navigation queries should use bounded/sliced work rather than long blocking
searches on the browser main thread.
NavigationAgent3Ddoes this automatically throughbeginSlicedPath()/updateSlicedPath()andqueryOptions.slicedIterationBudget. - Assimp is optional and often best disabled after content conversion. A lean target can use procedural or preconverted models without carrying FBX/glTF importer code.
- Context restoration recreates persistent mesh, material-texture, and shadow
resources; application-owned
ModelAssetdata must outlive its renderers.
The native test suite also builds and inspects feature-off and
Rendering3D-without-Assimp archives. It writes repeatable artifact-size reports
and complete nm link-symbol maps under
<build-directory>/snowpulse-reports/; the tests fail if disabled importer,
Jolt, Recast/Detour, or built-in 3D shader payloads leak into the lean target.
Deliberate exclusions¶
This foundation does not include deferred or clustered lighting, HDR/IBL, post-processing, cascaded/local-light shadows, terrain, occlusion culling, LOD, morph targets, root motion, IK, animation state machines, ragdolls, joints, vehicles, soft bodies, spatial audio, runtime navmesh rebaking, local avoidance, or 3D editor tooling. The native particle system remains 2D; Effekseer receives the active perspective view and depth state.
See examples/arpg for a compact procedural integration scene with PBR and
unlit materials, GPU animation, all three light types, a sun shadow, a dynamic
crate, click-to-move, and synchronized door/bridge route toggles.