This document describes the coordinate systems, transformations, and rendering techniques used by Rift.
Rift uses a top-left origin, Y-down coordinate system measured in pixels.
This matches typical 2D game and UI conventions where:
- Origin $ (0, 0) $ is at the top-left corner
- $ \hat{x} = (1, 0) $ points right
- $ \hat{y} = (0, 1) $ points down
The flat 2.5D pipeline transforms vertices through several coordinate spaces. Everything up to clip space happens on the CPU: the renderer builds each quad's four corners in view space and writes them into a batch buffer, so the only matrix the 2D vertex shader applies is the projection.
\htmlonly
flowchart LR
classDef space fill:#1e3a5f,stroke:#3b82f6,color:#e2e8f0
classDef transform fill:#134e3a,stroke:#10b981,color:#e2e8f0
classDef auto fill:#4a2020,stroke:#ef4444,color:#e2e8f0
subgraph CPU ["Game code + renderer CPU"]
World["World Space (world px)"]:::space
Camera["-camera"]:::transform
View["View Space (world px)"]:::space
Local["Quad corners (0,0)..(sx,sy)"]:::space
Model["Corner transform S, R about c, T(p)"]:::transform
Batch["Batch vertices (view space)"]:::space
end
subgraph Shader ["Vertex Shader"]
Proj["projection (scene or UI ortho)"]:::transform
Clip["Clip Space [-w,w] (x, y, z, w)"]:::space
end
subgraph GPU ["Automatic GPU"]
WDiv["/w"]:::auto
NDC["NDC [-1,1] (x, y, z)"]:::space
Viewport["viewport transform"]:::auto
Screen["Screen Space [0,W]x[0,H] px"]:::space
end
World --> Camera --> View
Local --> Model --> Batch
View -.->|"position"| Model
Batch --> Proj --> Clip
Clip --> WDiv --> NDC --> Viewport --> Screen
\endhtmlonly
Legend: 🟦 Coordinate spaces - 🟩 Transforms we implement - 🟥 GPU fixed-function
The 2D shader still declares a model uniform, but every batched path leaves it at identity;
only the Vulkan glyph path pushes a non-identity model matrix. The world-space 3D path
(DrawQuad3D) skips this chain entirely - see World-Space 3D Path.
Absolute pixel coordinates in the game world. Tile width and height are per-map data
(Tilemap::GetTileWidth() / GetTileHeight()) and are independent of each other, so a tile at
grid position
Coordinates relative to the camera's top-left corner. This answers: "where does this object appear on screen?"
What the camera position means:
View space coordinates:
-
$(0, 0)$ = top-left corner of screen -
$(viewWidth, viewHeight)$ = bottom-right corner -
$Values < 0$ = off-screen to the left/top -
$Values > viewSize$ = off-screen to the right/bottom
Pixel scale and zoom:
Screen pixels are not world pixels. Game::PIXEL_SCALE (5) is the integer upscale factor for the
pixel art, and camera zoom divides on top of it, so the visible world extent is:
That is viewScaling::VisibleWorldSizeZoomed(), the single source of truth shared by the ortho
projection, IRenderer::SetViewSize() and every culling test.
| Zoom=1 | Zoom=2 | |
|---|---|---|
| Screen size | 1920x1080 px | 1920x1080 px |
| Visible world | 384x216 world px | 192x108 world px |
How zoom works - we change the projection matrix:
// Zoom is applied by changing ortho() parameters
// (CameraController::GetOrthoProjection, called from Game::Render)
glm::mat4 P = glm::ortho(0.0f, visibleWidth, visibleHeight, 0.0f, -1.0f, 1.0f);| Zoom | ortho() right edge | Effect |
|---|---|---|
| 1.0 | 384 | View coord |
| 2.0 | 192 | View coord |
At zoom=2, the projection maps a smaller view range to the same NDC range
Why CPU-side:
The camera transform is done on CPU before rendering because:
- We need view positions for culling (skip off-screen tiles)
- The model matrix needs view-space position to place sprites
- Avoids passing camera uniform to every draw call
// CPU: compute view position
glm::vec2 viewPos = worldPos - cameraPos;
// Pass to renderer (becomes part of model matrix)
renderer.DrawSprite(texture, viewPos, size, rotation);The vertex buffer holds no shared unit quad. Each draw builds four local corners sized to the sprite, with the origin at the sprite's top-left:
The renderer rotates those corners about the sprite center, adds the view-space position, and pushes the result as six vertices (see Batch Structure). Local space therefore never reaches the GPU.
The 2D vertex shader applies only the projection, because the batch vertices are already in view space:
Where
After the perspective divide (trivial for orthographic projection since
NDC ranges from
The viewport transform maps NDC to framebuffer pixels. For viewport
The corner transform maps the unit quad to view-space pixels with position, scale, and rotation.
It runs on the CPU - DrawSpriteRegion builds the corners and IRenderer::RotateCorners rotates
them - but the matrix below is the exact composition it performs:
Where:
-
$\vec{p} = (p_x, p_y)$ - sprite position in view pixels -
$\vec{s} = (s_x, s_y)$ - sprite size in pixels -
$\vec{c} = \frac{1}{2}\vec{s}$ - sprite center (rotation pivot) -
$\theta$ - rotation angle in radians; theIRenderermethods take degrees and convert. Positive$\theta$ turns clockwise on screen, because Y points down.
This sequence:
- S - Scale the unit quad to sprite size
- T(-c) - Translate so the center is at the origin
- R - Rotate around the origin
- T(c) - Translate back
- T(p) - Translate to final position
Primitive matrices:
Step-by-step multiplication:
Step 1:
$$ \begin{pmatrix} 1 & 0 & 0 & -c_x \\ 0 & 1 & 0 & -c_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \times \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}
\begin{pmatrix} s_x & 0 & 0 & -c_x \\ 0 & s_y & 0 & -c_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$
Step 2:
$$ \begin{pmatrix} \cos\theta & -\sin\theta & 0 & 0 \\ \sin\theta & \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \times \begin{pmatrix} s_x & 0 & 0 & -c_x \\ 0 & s_y & 0 & -c_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}
\begin{pmatrix} s_x\cos\theta & -s_y\sin\theta & 0 & -c_x\cos\theta + c_y\sin\theta \\ s_x\sin\theta & s_y\cos\theta & 0 & -c_x\sin\theta - c_y\cos\theta \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$
Step 3:
$$ \begin{pmatrix} 1 & 0 & 0 & c_x \\ 0 & 1 & 0 & c_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \times \begin{pmatrix} s_x\cos\theta & -s_y\sin\theta & 0 & -c_x\cos\theta + c_y\sin\theta \\ s_x\sin\theta & s_y\cos\theta & 0 & -c_x\sin\theta - c_y\cos\theta \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}
\begin{pmatrix} s_x\cos\theta & -s_y\sin\theta & 0 & c_x(1-\cos\theta) + c_y\sin\theta \\ s_x\sin\theta & s_y\cos\theta & 0 & c_y(1-\cos\theta) - c_x\sin\theta \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} $$
Step 4:
Simplification insight: The
Maps view pixels SetProjection: the scene ortho, whose extent is the
zoomed world view, and - after the post-FX composite - the UI ortho, measured in screen
pixels. Each SetProjection call drains every pending batch, so it is also a hard painter-order
barrier.
This produces:
$x = 0 \rightarrow x_{NDC} = -1$ $x = w \rightarrow x_{NDC} = +1$ -
$y = 0 \rightarrow y_{NDC} = +1$ (top) -
$y = h \rightarrow y_{NDC} = -1$ (bottom)
Resulting NDC mapping:
The full transformation is split: the CPU applies
// shaders/Geometry.vert - model is identity for every batched path
gl_Position = projection * model * vec4(aPos, 0.0, 1.0);DrawSpriteRegion extracts a rectangular portion of a texture atlas using pixel coordinates:
Image files typically store pixels top-to-bottom (row 0 = top), but OpenGL's texture coordinate origin is at the bottom-left.
With flipY = true:
This is applied during UV calculation so sprite sheets work correctly regardless of how images are loaded.
Backend convention:
| API | Raw API convention | Rift sampling convention |
|---|---|---|
| OpenGL | Texture origin is bottom-left | flipY=true maps top-left image pixels to top-left sprites |
| Vulkan | UV origin is top-left, but tilesets are pre-flipped at load | Keeps the same flipY=true convention so one call site feeds both backends |
Both backends return true from IRenderer::RequiresYFlip(), so flipY is effectively always
true in engine code. It is distinct from tileFlipX / tileFlipY, which mirror the sampled
source region per tile and are applied before rotation.
Vulkan texture uploads must happen outside an active render pass. A draw call that references a texture without a Vulkan image view uses the renderer's white fallback texture rather than stalling to upload mid-frame:
\htmlonly
sequenceDiagram
participant Game
participant Renderer as "VulkanRenderer"
participant Texture
participant GPU
Game->>Renderer: UploadTexture(texture)
Renderer->>Texture: CreateVulkanTexture(...)
Texture->>GPU: Staging copy + image layout transition
Game->>Renderer: DrawSpriteRegion(texture, ...)
Renderer->>Texture: GetVulkanImageView()
alt Texture uploaded
Renderer->>GPU: Bind descriptor and draw textured quad
else Texture missing image view
Renderer->>GPU: Bind white fallback and draw quad
end
\endhtmlonly
OpenGL batches consecutive sprites that share the same texture into a single draw call. When the texture changes, the current OpenGL batch is flushed and a new batch begins. Vulkan currently submits each quad directly with cached descriptor sets and persistent per-frame vertex buffers.
\htmlonly
sequenceDiagram
participant Game
participant OpenGL
participant GLBatch as "OpenGL Batch"
participant Vulkan
participant GPU
Game->>OpenGL: DrawSprite(tex1, pos1)
OpenGL->>GLBatch: Add vertices
Game->>OpenGL: DrawSprite(tex1, pos2)
OpenGL->>GLBatch: Add vertices
Game->>OpenGL: DrawSprite(tex2, pos3)
Note over OpenGL: Texture changed
OpenGL->>GPU: Flush batch (tex1)
OpenGL->>GLBatch: Add vertices (tex2)
Game->>Vulkan: DrawSprite(tex1, pos1)
Vulkan->>GPU: vkCmdDraw(quad)
\endhtmlonly
Each sprite adds 6 vertices (2 triangles) to the batch - corners 0 (TL) and 2 (BR) are duplicated rather than using an index buffer:
struct BatchVertex {
float x, y; // View-space position (camera already subtracted)
float u, v; // UV coordinates
};
// Two triangles per quad, counter-clockwise (corners 0 and 2 duplicated)
// 0 -------- 1
// | \ |
// | \ | Triangle 1: 0-2-3
// | \ | Triangle 2: 0-1-2
// | \ |
// 3 -------- 2The OpenGL backend keeps four independent 2D batches plus the world-space 3D batch. They differ in vertex format and in what ends them early:
| Batch | Vertex format | Own flush trigger |
|---|---|---|
| Sprites | position + UV | Texture change |
| Rects | position + UV + RGBA | Blend-mode change |
| Particles | position + UV + RGBA | Texture or blend-mode change |
| Text | position + UV + RGBA | Atlas change, quad budget |
| 3D quads | scene position + UV + RGBA | Texture, blend or depth mode |
MAX_BATCH_SPRITES (10000 quads) caps the sprite, rect, particle and 3D buffers; text has its
own MAX_TEXT_QUADS budget and does not flush per DrawText call.
Beyond its own trigger, a batch is also drained by:
- A full buffer, or a switch to a different batch type.
SetProjectionandEndFrame- these drain every batch, 2D and 3D.SetAmbientColor(sprite batch),SetViewProjection(3D batch),DrawQuad3D(sprite, rect and particle batches first).BeginScene/EndSceneApplyPostFX- every batch except text, so text queued inside the scene pass reaches the swapchain after the composite and is never graded.
Cross-type drains are asymmetric. The drain matrix in OpenGLRenderer.hpp is the authority on
which call pairs preserve painter order.
Some tiles (buildings, signs) represent objects that stand up rather than lying flat on
the ground. These carry TileStance::Structure (see src/TileStance.hpp). In the flat
2D pipeline they are drawn exactly like any other tile - the stance changes only their
draw ORDER, never their geometry. The world3d orbit-camera path is where a stance
becomes real geometry, turning the tile into an upright billboard.
stance is a per-map-cell, per-layer field replacing the old noProjection boolean. The flat
pipeline only distinguishes Structure from everything else; the remaining stances (Prop,
Wall) are 3D concepts and read as ordinary flat artwork here. Maps written before the field
existed are migrated once, at load - see Tilemap::LoadMapFromJSON.
All drawing uses standard alpha blending:
OpenGL:
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);Vulkan:
colorBlendAttachment.blendEnable = VK_TRUE;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;For glowing effects (particles, light rays):
Enabled via the additive flag on DrawSpriteAlpha, DrawSpriteAtlas and DrawColoredRect.
OpenGL honours it; the Vulkan 2D path ignores it until it has a second additive-blend pipeline.
DrawQuad3D is unaffected: it takes an explicit renderModes::BlendMode, and Vulkan pre-builds
one pipeline per blend/depth combination, so additive world geometry works on both backends.
Game::Render() selects one of three self-contained paths and never mixes them:
| Path | Entry point | When |
|---|---|---|
| Title screen | RenderTitleFrame |
GameMode::Title |
| World-space 3D | RenderFrame3D |
world3d console toggle is on |
| Flat 2.5D | rest of Render() |
default gameplay path |
The flat path renders in this order for correct depth. BeginFrame() then BeginScene() run
first, so steps 1-10 accumulate in the offscreen scene target; EndSceneApplyPostFX() ends that
target, and every pass after it draws straight to the swapchain.
\htmlonly
flowchart LR
subgraph Background
A1["1. Clear (sky color)"]
A2["2. Background layers"]
A3["3. Upright background tiles"]
end
subgraph YSorted["World Depth Pass"]
B1["4. Authored baseline + structure-local support constraints
(atomic characters)"]
end
subgraph Foreground
C1["5. Upright foreground tiles"]
C2["6. Upright-tile particles"]
C3["7. Foreground layers"]
C4["8. World particles"]
end
subgraph Sky["Lights and Sky"]
D1["9. World light pools (additive)"]
D2["10. Sky / ambient overlay"]
end
subgraph Post["Composite"]
E1["11. EndSceneApplyPostFX"]
end
subgraph UI["Swapchain, ungraded"]
F1["12. Editor / UI overlays"]
F2["13. Dialogue, debug HUD, console"]
end
Background --> YSorted --> Foreground --> Sky --> Post --> UI
\endhtmlonly
Steps 3 and 5 are the upright (TileStance::Structure) tiles, drawn by
Tilemap::RenderBackgroundLayersNoProjection / RenderForegroundLayersNoProjection. Step 9 only
contributes once TimeManager::GetStarVisibility() exceeds 0.01, and each light is further
scaled by ComputeLightIntensity(schedule, hour).
Explicit Y-sort tiles, elevated object/foreground tiles, and characters are collected into one list. The first pass preserves the map's authored roles:
Background → Y-sorted actors/tiles → Foreground
Inside a phase, authored painter depth remains:
Elevation is intentionally not a global depth offset. Plane relationships are added by the local structure constraints below; folding elevation into every Y-sort comparison would make ramp and railing art cover unrelated actors standing beside the structure.
struct Drawable {
DrawablePhase phase; // authored background / Y-sort / foreground role
int surfaceRegionId; // connected elevation footprint, or -1
SupportSurface supportSurface; // actor topology; ignored for tiles
float sortY; // authored depth key; smaller sorts further back
float supportHeight; // metadata for the local support relationship
bool isYSortMinus; // tile occlusion flag; false for entities
std::uint8_t tieBias; // equal-depth order: tile (4), NPC (3), player (1)
DrawableClass cls;
};The elevation map is flood-filled into connected runtime region IDs. Explicit Y-sort artwork inherits a region through its connected Y-sort component or authored structure ID, allowing railings and visual overhangs outside the walkable cells to stay associated with the bridge.
After baseline sorting, a stable topological pass adds only these local constraints:
ground actor inside region → every tile of that region (walking underneath)
background surface tile → elevated actor in the same region (walking on the deck)
authored Y-sort tile ↔ elevated actor in the same region (railings)
An actor beside the ramp has no elevation-region ID, so none of these constraints applies.
ySortPlus and ySortMinus always retain their authored Y-sorted phase—even when the artwork is
stored on a foreground layer—and therefore keep the same tie/anchor behavior the map author set.
The same railings are explicitly evaluated against deck actors and are forced above a ground actor
only while that actor is actually under the bridge footprint.
Each character contributes one atomic queue item. The current player/NPC renderer still submits two sprite regions, but both submissions execute consecutively from that item. No tile can be inserted between feet, body, hair, hats, equipment, or a future taller sprite; sprite dimensions are not part of the sorting contract.
The ground and ground-detail layers stay in the fixed background pass. On a non-zero elevation cell, object and foreground artwork is automatically promoted out of its fixed pass and into the world depth queue, preventing double rendering while retaining the layer's original phase.
BeginScene() redirects the scene into an offscreen target; EndSceneApplyPostFX(params)
composites it into the swapchain. Everything drawn afterwards - editor, dialogue, debug HUD,
console - bypasses the chain and stays sharp and ungrained.
On OpenGL the composite runs an HSV-saturation bright pass over the scene texture, builds and
additively upsamples a bloom mip chain, then draws a full-screen triangle that applies, in order
(shaders/PostFXComposite.frag):
- Scene sample with radial chromatic aberration (3 fetches).
- Chroma-only bloom add - luma-orthogonal, so it tints without brightening.
- Lift/gamma/gain grading, split per time of day.
- Saturation pump.
- Vignette + edge desaturation (elliptical smoothstep from screen center).
- Film grain (luminance-modulated, 2x2 pixel tiles).
- Soft-shoulder tonemap.
PostFXParams::postFXEnabled is the master gate; when it is 0 the shader returns the raw scene
texel. The console command is postfx [on|off|toggle].
PostFXComposite.frag is OpenGL-only: it is not in CMake's SHADER_SOURCES, is never compiled
to SPIR-V, and its default-block uniforms are illegal in Vulkan GLSL. On Vulkan both
BeginScene() and EndSceneApplyPostFX() are no-ops - the scene has already rendered to the
swapchain, so every field of params is ignored and the frame ships without bloom or grading.
The world3d console toggle switches Game::Render() to RenderFrame3D(), an in-progress
world-space orbit camera. It is not the default path; both are compiled in and the flat path is
untouched while the toggle is off. Tiles, characters, particles, world light pools and the full
sky all draw here. What remains flat-pipeline only is the editor's zone overlays and picking,
dialogue boxes, NPC head text and the debug HUD.
The 3D path does not use the 2D primitives at all. CameraRig builds one projection * view
matrix, published through SetViewProjection(), and every piece of scene geometry - ground
tiles, upright billboards, characters and particles - is submitted through DrawQuad3D() as four
scene-space corners. Unlike every other draw method, DrawQuad3D does not take
camera-pre-subtracted coordinates: the matrix does that work. Depth is a real depth buffer,
selected per quad by renderModes::DepthMode, instead of submission order, and Frustum planes
extracted from the same matrix cull off-screen geometry.
"Does not use the 2D primitives at all" is load-bearing, not incidental. DrawQuad3D drains the
pending 2D batches before it records, but no 2D primitive drains the pending 3D batch, and
EndSceneApplyPostFX flushes 2D first and 3D last - so a DrawColoredRect issued after a
DrawQuad3D, with no projection switch between them, would render underneath the 3D geometry
submitted before it. That is why the sky's two untextured bands become textured quads here
instead of staying rects.
shaders/Geometry3D.vert / .frag back this path; Geometry.vert / .frag stay in use for the
flat world and for all screen-space UI, which must never be projected.
Tile stance becomes real geometry here: TileStance::Prop turns to face the camera,
TileStance::Wall and TileStance::Structure stay locked to the grid as upright surfaces (see
Upright Tiles).
A particle has no altitude - Particle::position is a 2D world pixel and "falling" is
+velocity.y - so world3d draws each one as the same 2D sprite on a camera-facing card
rather than as a point in space. All four cards share one orientation,
billboard::Orient(yaw, pitch, {1, 1}), which makes them exactly perpendicular to the view
ray: the lean equals the pitch (51.34 deg under the DS preset) against 41.07 deg for structure
billboards, and leanFollow must stay 1.0 or the whole image foreshortens by
cos(pitch * (1 - k)).
| Card | Who | Anchor | Depth | Cull |
|---|---|---|---|---|
| Sheet | weather, ambient and console particles | the rig's ground focus | None |
the flat rect, rephrased about the focus |
| Ground prop | zone particles with no net vertical motion | the particle itself | None |
frustum sphere |
| Zone card | zone particles that fall or rise | the focus clamped to the zone | None |
frustum sphere |
| Facade | no-projection particles on a structure | the body's foot | TestOnly |
never |
The sheet is a plane of constant camera depth D = DistanceForVisibleHeight(visible.y, fov),
and the rig frames visibleWorldSize exactly at that depth under both projections - so one
world pixel of offset on the sheet is one flat screen pixel at every yaw and pitch, and rain
still falls straight down the screen at yaw 180. The facade card is biased 1 px toward the eye
so a decal clears its own wall (0.984 px at DS) while a nearer building still occludes it.
The per-type choice between ground prop and zone card is one field on that type's
kParticleVisuals row.
SkyRenderer builds one skyDraw::List per frame and replays it twice: SubmitFlat through the
2D sprite primitives, Submit3D as sheet quads. Building once and submitting twice is what keeps
the two paths from drifting apart, and it is what makes the sky testable without a graphics
context.
Every sky element rides the same sheet the weather particles use, so the sky renders as an unrotated, unforeshortened copy of the flat sky at every yaw and pitch.
| Layer | Anchor | Depth | Cull |
|---|---|---|---|
| Washes (dawn gradient and horizon, atmospheric bands, flash) | viewport fractions, on the sheet | None |
sheet rect (never drops one) |
| Stars, shooting stars, aurora curtains, halos, beams, wisps | their wrapped world position | None |
sheet rect |
| Sun and moon rays | world-anchored in X only; their Y is viewport-relative | None |
sheet rect |
| Light pools | the lamp's world position at its surface height | None |
frustum sphere, radius * 1.5 |
The whole transform is one line, and the camera cancels out of it:
That is why no element has to declare whether it is world-anchored or viewport-anchored: a star
subtracts the camera position before it gets here and a dawn wash does not, and both land
correctly. It is also why the std::remainder wrap phantoms the star field and aurora compute
need no special handling - the formula reconstructs whatever point the flat path chose.
Every sky quad is Additive and SelfLit. The two untextured atmospheric bands sample a
generated opaque-white texture here, because the world-space path has no untextured primitive;
on the flat path they stay colour-only rects.
The procedural sky textures are pre-cut at the flat shader's 0.1 alpha threshold, so both
fragment paths cut out on the same contour. Without that the flat frame would show an 800 px dawn
glow as a ~456 px disc while the 3D frame drew it at full width, because Geometry.frag discards
on texture alpha below 0.1 and Geometry3D.frag on product alpha below 1/255.
Submissions are capped at skyCards::MAX_SKY_QUADS_3D, which the shipped content never reaches.
The cap reserves the flash, the bolt, the rays, the dew and the washes, so an overrun thins the
aurora and star layers at stride and never costs a lightning bolt. The sky is one texture and one
batch only while the atlas is bound; with no atlas each sprite falls back to its own texture.
The particle system provides ambient visual effects through physics-based motion and procedural animation.
Each particle has lifetime
Where
Fade Curves:
Fade-in over duration
Fade-out over duration
Combined fade envelope: $$ \alpha_{fade} = \alpha_{in} \cdot \alpha_{out} $$
Each behavior multiplies this envelope by its own animation term and a per-type base alpha, so
Basic Euler Integration:
Position update each frame with timestep
Sinusoidal Drift (Fireflies, Wisps):
Adds oscillating displacement using particle phase
Where:
-
$A_x, A_y$ = drift amplitude (pixels/second) -
$\omega_x, \omega_y$ = angular frequency (radians/second) -
$k$ = phase multiplier for Y (creates varied paths)
Fireflies use
Pulsing Glow (Fireflies):
A unit sine pulse on global time, scaled by the fade envelope and the type's base alpha (0.7): $$ \alpha_{pulse} = \tfrac{1}{2} + \tfrac{1}{2}\sin(\omega t + \phi), \quad \omega = 4 $$ $$ \alpha = \alpha_{pulse} \cdot \alpha_{fade} \cdot 0.7 $$
Per-particle
Sparkle Twinkle:
Sparkles use a fast attack and a quadratic decay over life progress
Angular velocity
Fireflies use
Direction alternates on the same phase value, so roughly half of each population spins the other way: $$ \overset{\cdot}{\theta}' = \begin{cases} -\overset{\cdot}{\theta} & \text{if } \phi \bmod 2 < 1 \\ +\overset{\cdot}{\theta} & \text{otherwise} \end{cases} $$
ParticleSystem::Render is called twice per frame: once for the particles that ride upright
tiles (step 6 of the render order) and once for world particles (step 8), both before the light
pools and the sky.
Within each call, the visible particles are partitioned - not sorted - by blend mode, so relative order inside a group is the spawn order:
- Non-additive particles (fog, rain) - standard alpha blending
- Additive particles (fireflies, sparkles, wisps) - glow blending
Upright-tile particles are never viewport-culled, because they are projected onto the structure mesh underneath them. World particles are culled against the view rect with a size-based pad.
ParticleSystem::Render3D is the world-space counterpart and is called once per frame from
Game::RenderFrame3D, after the tile and actor passes and before post-FX. It submits pass B1 -
facade decals, DepthMode::TestOnly - and then pass B0 - every other card, DepthMode::None.
Within each pass the same non-additive-first partition applies, and there is no depth sort,
because the cards in a pass are coplanar. Every quad carries
renderModes::LightMode::SelfLit, so fog, aurora and constellations do not grey out at night.
Submissions are capped at ParticleSystem::MAX_PARTICLE_QUADS_3D: the Vulkan backend's
per-frame 3D vertex buffer is shared with the tile pass and silently drops the overflow.
The rendering system uses a backend-agnostic interface to support multiple graphics APIs:
\htmlonly
classDiagram
class IRenderer {
<>
+Init() bool
+Shutdown()
+BeginFrame()
+EndFrame()
+BeginScene()
+EndSceneApplyPostFX(params)
+DrawSprite()
+DrawSpriteRegion()
+DrawSpriteAlpha()
+DrawSpriteAtlas()
+DrawColoredRect()
+DrawQuad3D()
+DrawText()
+SetProjection()
+SetViewProjection()
+SetViewport()
+Clear()
+UploadTexture()
+SetAmbientColor()
+RequiresYFlip() bool
+GetBackendInfo() RendererInfo
}
class OpenGLRenderer {
-m_ShaderProgram
-m_BatchVAO
-m_BatchVBO
-m_TextureCache
+FlushBatch()
}
class VulkanRenderer {
-m_Instance
-m_Device
-m_SwapChain
-m_CommandPool
+CreatePipeline()
}
class RendererFactory_h {
<>
+CreateRenderer(api, window)$ unique_ptr
+IsRendererAvailable(api)$ bool
}
IRenderer <|.. OpenGLRenderer : implements
IRenderer <|.. VulkanRenderer : implements
RendererFactory_h ..> IRenderer : creates
style IRenderer fill:#1e3a5f,stroke:#3b82f6,color:#e2e8f0
style OpenGLRenderer fill:#134e3a,stroke:#10b981,color:#e2e8f0
style VulkanRenderer fill:#134e3a,stroke:#10b981,color:#e2e8f0
style RendererFactory_h fill:#4a2020,stroke:#ef4444,color:#e2e8f0
\endhtmlonly
Legend: 🟦 Interface - 🟩 Implementations - 🟥 Factory
The IRenderer interface provides all drawing operations. Game code calls these methods without knowing which backend is active:
// Game code doesn't know if this is OpenGL or Vulkan
renderer->DrawSprite(texture, position, size, rotation);
renderer->DrawColoredRect(position, size, color);RendererFactory.hpp provides free helper functions that create the appropriate backend based on
configuration or availability. api is taken by reference so the factory can report the backend
it actually created after a fallback:
RendererAPI api = RendererAPI::OpenGL;
std::unique_ptr<IRenderer> renderer = CreateRenderer(api, window);Both backends are always compiled in - Vulkan is a hard find_package(Vulkan REQUIRED) - so
there is no build configuration in which one of them is absent.
Two constraints follow from that design:
- Override sets must stay identical. Adding, removing or re-signing a pure virtual in
IRenderer.hpprequires the matching edit inRendererMacros.hpp.RIFT_DECLARE_COMMON_RENDERER_METHODSis what keeps both backends byte-identical, so the two files are always edited together. - Nothing may cache an
IRenderer*. Therenderer.set opengl|vulkanconsole command destroys and recreates the GLFW window and the renderer, after whichTextureStorere-uploads every texture. Any pointer, reference or texture handle captured before the switch dangles; take the renderer by reference per call instead.
- Architecture - System design overview
- Time System - Ambient lighting that affects rendering