Skip to content

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, and u_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 set u_Instanced and 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, and u_Unlit.
  • Material textures: u_BaseColorTex, u_NormalTex, u_MetallicTex, u_RoughnessTex, u_EmissiveTex, u_OcclusionTex, and u_AlphaTex, their matching u_Has*Tex flags, the six u_*UvSet selectors, and u_MetallicChannel, u_RoughnessChannel, u_OcclusionChannel, and u_AlphaChannel.
  • Lighting: u_AmbientColor/u_AmbientIntensity, the u_Directional* values, u_PointLightCount plus the four u_Point*[8] arrays, and u_SpotLightCount plus the seven u_Spot*[4] arrays.
  • Sun shadow: u_ShadowMap, u_LightViewProj, u_ShadowEnabled, u_ReceivesShadows, u_ShadowBias, and u_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. NavigationAgent3D does this automatically through beginSlicedPath()/updateSlicedPath() and queryOptions.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 ModelAsset data 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.