Rasterization - SethKitchen/ThreeMojo GitHub Wiki
Rasterization
render/rasterizer.mojo, render/fillrule.mojo and renderers/clip.mojo. The rasterizer fills screen-space triangles into a RenderTarget. It decides coverage in fixed point, tests depth, interpolates with perspective correction, and shades every fragment.

three.js has no software rasterizer. This is the part of the port that replaces the GPU pipeline.
RasterVertex
One corner as the rasterizer wants it:
| Field | Meaning |
|---|---|
x, y |
Pixel coordinates. |
z |
Depth in normalized device space. |
inv_w |
One over the clip-space w. |
color |
The material color, linear, with opacity in alpha. |
normal |
The world-space normal. |
u, v |
Texture coordinates. |
world |
The world-space position, for point lights. |
texture |
A TextureId, or NO_TEXTURE. |
blend |
OPAQUE or BLEND. |
kind |
The material kind: LAMBERT, PHONG, BASIC, NORMALS or DEPTH. |
specular |
How much a PHONG surface sends to the camera, linear. |
shininess |
How tight its highlight is. |
alpha_map |
The TextureId whose green channel thins the surface, or NO_TEXTURE. |
alpha_test |
The alpha a fragment must reach to be drawn. |
emissive |
Light the surface gives off, linear. |
emissive_map |
The TextureId that multiplies emissive, or NO_TEXTURE. |
view_depth |
The camera-space depth in meters, for the fog. See Fog. |
texture, blend, kind, emissive_map, alpha_map, alpha_test and shininess are per-triangle state. All three corners must agree, and the value must be a named one. check_triangle_state refuses anything else.
A NORMALS corner carries its normal in view space, not world space. The normal is shown rather than lit.
Functions
| Function | Meaning |
|---|---|
rasterize(triangle, framebuffer, color) |
Fill a flat triangle. |
rasterize_depth(...) |
Fill with a depth test. |
rasterize_shaded(a, b, c, target, mode, textures, lighting, first_row, last_row, fog) |
Fill one shaded triangle. |
rasterize_all(corners, target, mode, textures, lighting, workers, fog) |
Fill a whole list of triangles, on one or more threads. |
rasterize_lines_all(corners, target, workers, fog) |
Draw a whole list of segments, on one or more threads. |
rasterize_point(point, target, mode, textures, first_row, last_row, fog) |
Draw one point, a square of pixels. |
rasterize_points_all(points, target, mode, textures, workers, fog) |
Draw a whole list of points, on one or more threads. |
rasterize_frame(corners, segments, draws, target, mode, textures, lighting, workers, fog, points) |
Draw triangles, segments and points in one order. rasterize_all, rasterize_lines_all and rasterize_points_all are each one draw of it. |
rasterize_frame(corners, surfaces, segments, draws, target, ...) |
The same, with slim triangles: each Corner names its Surface. See Slim corners. |
Draw(kind, first, count) |
One run of DRAW_TRIANGLES, DRAW_SEGMENTS or DRAW_POINTS in a frame's order. |
check_draws(draws, triangles, segments, points) |
Refuse a draw that names a run the frame does not hold. |
check_point_state(point), check_point_maps(point, mode, textures) |
Refuse a point that is lit, has no size, or whose alpha map is not stored as data. |
check_triangle_maps(a, mode, textures) |
Refuse a triangle whose maps are not stored the way its shader reads them. |
check_triangle_state(a, b, c) |
Refuse corners that disagree, or hold a value neither backend knows. |
mip_level(du, dv, width, height) |
The mip level for a texture footprint. |
data_color(r, g, b, a) |
Three channels of data as the light that resolves to their bytes. |
packed_normal(normal) |
A unit normal mapped into zero to one per axis. |
packed_depth(z) |
An NDC depth as the gray a DEPTH material shows. |
check_alpha_map(texture) |
Refuse an alpha map that is not stored as data. |
check_gradient_map(texture) |
Refuse a toon ramp that is not stored as data. |
gradient_ramp(texture) |
A gradient map's top row as tones, left to right. |
matcap_uv(toward_eye, up, normal) |
Where a MATCAP surface is looked up in its image. |
matcap_fallback(v) |
three.js's gray gradient, for a matcap surface with no image. |
interpolate_alpha(a, b, c, share_b, share_c) |
An alpha across a triangle, in difference form. |
Coverage
Vertices snap to a 1/16 pixel grid. The edge function is then exact integer arithmetic. The top-left rule gives a pixel on a shared edge to exactly one triangle. See Why coverage uses fixed point.
Depth
Depth is interpolated linearly in screen space, and the renderer's depth mode then sets what is stored. By default a fragment is kept when it is as near as what is there, or nearer: three.js's LessEqualDepth. An opaque fragment writes its depth. A blended fragment tests depth and does not write it. Each corner carries a state, a RasterState, that changes these rules and adds the stencil test. See Materials.
An alpha-tested fragment writes its depth and its stencil late. It tests without claiming, and claims with claim_depth once it survives the test. A fragment the test throws away leaves the depth alone, so the hole shows what is behind it. A GPU does the same for a shader that can discard.
Logarithmic depth
Renderer.set_depth_mode(LOGARITHMIC_DEPTH) stores a depth that grows with the log of the distance: three.js's logarithmicDepthBuffer. Use it for a scene with a small near distance and a large far distance, where the standard depth has no precision left far away.
Each fragment computes its own depth from its own w, as three.js's logdepthbuf_fragment does:
depth = log2(1 + w) * logDepthBufFC - 1
logDepthBufFC = 2 / log2(far + 1)
w is one over the interpolated inv_w, which is the perspective-correct w. three.js writes half of log2(1 + w) * logDepthBufFC to gl_FragDepth. This port keeps the depth on the scale of NDC depth, from minus one to one, so the result is that value doubled, less one. The depth is not linear in screen space, so each fragment computes it again. render.raster_state.fragment_depth holds the arithmetic, and both backends call it.
The mode applies only to a perspective camera. Under an orthographic camera depth_mode_for returns STANDARD_DEPTH, as three.js keeps gl_FragCoord.z when vIsPerspective is zero.
These rules are different from the standard depth:
- The depth comes from
wand not fromz, so a polygon offset has no effect. three.js loses it the same way, because a shader that writesgl_FragDepthreplaces the offset depth. - A
DEPTHmaterial still shows the standard depth as its gray, fromz.
Reversed depth
Renderer.set_depth_mode(REVERSED_DEPTH) stores 1 at the near plane and 0 at the far plane: three.js's reversedDepthBuffer. A clear writes minus infinity, and every depth function is turned round. LESS_EQUAL_DEPTH then keeps a fragment with a larger depth, as three.js's ReversedDepthFuncs maps it to GreaterEqualDepth. EQUAL_DEPTH, NOT_EQUAL_DEPTH, ALWAYS_DEPTH and NEVER_DEPTH do not change.
The stored depth is (1 - z) / 2, where z is the NDC depth. A polygon offset still pushes a surface away, because it moves z before the depth is reversed. A DEPTH material still shows the standard depth as its gray.
This port computes the reversed depth from the NDC depth. So it keeps the ordering and the clear of three.js's reversed buffer. It does not get the extra floating-point precision that a reversed projection matrix gives a GPU.
What reads the depth
A RenderTarget records the mode of its last clear in depth_mode. Every reader of the depth uses that mode:
RenderTarget.downsampledandrender.antialias.downsamplekeep the nearest depth of a block, which is the largest one underREVERSED_DEPTH.RenderTarget.depth_textureshows the stored depth from zero to one. UnderREVERSED_DEPTHa near surface is white and the clear is black, as three.js'sDepthTextureholds it.DepthView, and so SSAO, SAO, SSR and the bokeh pass, read every mode back into the standard window depth. See Post-processing.- The outline pass, the SSAA pass and the clear pass use the mode of the frame.
The shadow maps always use the standard depth, because the shadow comparison reads the standard depth. A light's view of the casters ignores the renderer's mode.
On the GPU
The mode rides bits 19 and 20 of the packed depth state, and the log factor rides the vertex lane LANE_LOG_DEPTH. The kernel compares a fragment with the clear of the fragment's own mode until a depth is claimed. GpuRenderer.draw takes depth_mode, so that read_back returns minus infinity for a pixel that nothing claimed under REVERSED_DEPTH. tests/test_gpu.mojo compares the pixels and the stored depth of both backends in each mode.
Clipping
A clip-intersection union assigns geometry on a shared plane boundary to its first piece. Repeated planes do not draw a coplanar triangle or segment twice. Public depth-clipping calls require finite, ordered near and far planes. A finite negative near plane remains valid for an orthographic view.
clip_depth(a, b, c, near, far, sides) cuts a triangle against the near and far planes in camera space, and then against the four side planes in sides. It returns zero or more triangles, fanned from the first surviving corner. Every varying is interpolated to the cut, including the world position and the distance along a line. clip_segment cuts a segment the same way. The sides come from Frustum.side_planes(camera.projection_matrix()), the same planes the culler reads in world space. Without them a triangle past the edge of the view still projected onto the target, and a viewport smaller than the target drew it.
Culling
The material's side decides which faces are drawn. FRONT_SIDE culls faces that point away from the camera. A mesh with a negative world determinant has its winding reversed, and the culler reads the determinant.
Interpolation
Color, normal, texture coordinates and world position are interpolated with perspective correction. Each corner's value is weighted by inv_w, and the sum is divided by the interpolated inv_w. See Why interpolation is perspective-correct.
Shading
At each fragment the interpolated normal is normalized again, and Lighting.intensity_at sums every light. The material color, the sampled texel and the light multiply. A PHONG triangle then adds its highlight, which Lighting.specular_at sums. The texture does not tint it. The emissive term, times its own map, is added next, and the lights do not touch it.
A BASIC triangle skips all of this. See Why shading is per fragment.
Data
A NORMALS or DEPTH triangle writes data rather than light. The fragment discards what the color and the texture said about red, green and blue. It keeps what they said about alpha, so a map cuts a depth out.
data_color quantizes the three channels to bytes without the sRGB curve, then decodes those bytes back into the linear buffer. resolve encodes them and gives the same bytes. The target records that the pixel holds data, and keeps the tone mapping off it. The SHADE_UV debug view writes its coordinates the same way. See Why a normal is not a color.
Neither the emissive term nor the fog reaches such a fragment.
Fog
After the lights and the emissive term, the fragment is mixed toward the fog color by its camera-space depth. The fog argument is a FogView. The default, FogView.none(), changes nothing. SHADE_UV is never fogged, and nor is a NORMALS or DEPTH triangle. See Fog.
Alpha
The alpha map's green channel multiplies the fragment's alpha, three.js's alphamap_fragment. The map must be LINEAR and IGNORED: its green is a coverage, not a color. check_alpha_map refuses anything else, on both backends, before the first fragment.
A fragment whose alpha then falls below alpha_test is thrown away, three.js's alphatest_fragment. The comparison is strict. A test of zero throws nothing away. SHADE_LIT ignores the map and keeps the test. SHADE_UV ignores both.
interpolate_alpha reaches that comparison. The three barycentric weights are rounded floats, so they sum to one only up to rounding. a * sa + b * sb + c * sc for three alphas of one can give 0.99999994. A test of one then throws away a surface that is opaque everywhere.
The difference form, a + sb * (b - a) + sc * (c - a), returns a constant exactly. Alpha alone uses it: alpha reaches a strict threshold, and red, green and blue reach a quantization that a last bit cannot move. Both backends call this one function.
Toon shading
A TOON triangle reads its light from Lighting.toon_at rather than from intensity_at. Its ramp is read once per triangle by gradient_ramp, not once per fragment. The one row is the whole lookup table, and it is the same for every pixel. An empty ramp means the triangle named none, and three.js's fallback applies.
A ramp is one row high, and check_gradient_map refuses a taller image on both backends. Under this project's convention a v of zero is the bottom row. So "the first stored row" and three.js's vec2(coord, 0.0) name different rows in a taller image, and one row has only one. See Materials.
Both backends then read the same bytes. The host reads texel (x, 0), and the kernel reads that same byte out of its texel buffer.
Matcap shading
A MATCAP triangle is unlit. Its light comes from an image, looked up by which way the surface is turned in the camera's frame. That light multiplies the surface color exactly as arriving light does. matcap_uv builds that frame and both backends call it.
The direction toward the camera is measured from where the camera stands, under either projection. The image is read at its full-size level and never down a mip chain. See Materials.
Threads
rasterize_frame splits the image into horizontal bands and runs one task per band. Every draw is offered to every band, and a primitive whose rows miss the band is skipped on two integer compares. A band owns its rows outright, so no two threads touch the same pixel and the depth test needs no atomics.
The tasks come from TaskGroup. Mojo 1.1 moved that behind an underscore. std.runtime keeps only parallelism_level and initialize_runtime in public view, and nothing public in std runs work on a thread pool. So from std.runtime._asyncrt import TaskGroup is the one place this project reaches past a leading underscore, and it is what pins the toolchain to an exact version. Mojo 1.0 has no _asyncrt and 1.1 has no asyncrt, so one source cannot serve both.
Slim corners
A RasterVertex holds a corner's own values and its whole material: 1024 bytes. A Corner holds only what differs from corner to corner, such as its place, its color, its normal and its texture coordinates: 256 bytes. It names a Surface, which holds the material that every corner of one draw shares.
Renderer.prepare_frame keeps the triangles this way, so a frame writes and reads a quarter of the memory. rasterize_frame asks each surface its checks once. Then, as it draws each triangle, it joins the three corners to their surface again with joined. The whole corners stay in the cache for that one triangle.
corner_of(vertex, surface) and surface_of(vertex) split a whole corner. Frame.whole_corners() gives the frame's corners whole, as prepare returns them and GpuRenderer.draw takes them.
Two output representations
A NORMALS or DEPTH fragment writes bytes a display must show as they are. Every other kind writes light, which the tone mapping curve compresses. One pixel cannot hold both.
check_triangle_state refuses a blended data triangle, which closes one direction. check_output_kinds closes the other. A frame that resolves through a curve must not hold both a data material and a blended one.
That refusal is a whole-frame rule rather than a per-pixel one, and both backends ask it on the host before anything is drawn. A kernel cannot raise part way through a frame, so a check that fired only where the two actually overlap could not be shared.
Why not resolve it per pixel. RenderTarget.blend resolves a mixture as light, and that answer does not fade out with the alpha. A black surface at an alpha of 1e-8 leaves the stored color bit for bit identical, because 1 - alpha rounds to one in Float32. It still turns the curve on for the normal underneath, which moves (128, 128, 255) to (117, 117, 188) through Reinhard. Making the mixture data instead would let scene light escape the curve, and a threshold would only move the jump to the threshold. Draw the data in a pass of its own, or set NO_TONE_MAPPING.
Transparency
rasterize_shaded blends a BLEND fragment over what is there, in premultiplied linear light. The caller owns the draw order. Renderer.prepare sorts.
A NORMALS or DEPTH triangle cannot blend. One pixel holds its own bytes or the scene's light, and a mixture of the two is neither. check_triangle_state refuses the pair, on both backends and on every worker count. So RenderTarget.blend can say a mixture is always light. See Materials.
Errors
- A mode that is none of the three raises.
- Corners that disagree about blend, texture, kind, emissive map, gradient map, matcap or shininess raise.
- A negative or non-finite shininess raises, on every worker count.
- A material kind that is none of the seven raises, on every worker count.
- An emissive map that reads its alpha as coverage raises under
SHADE_TEXTURE, on both backends. - A blend value that is neither
OPAQUEnorBLENDraises, on every worker count, whether or not the triangle is visible. - A
NORMALSorDEPTHtriangle whose blend isBLENDraises, on every worker count. - A texture the store lacks raises when a fragment samples it.
- An alpha map that
SHADE_TEXTUREwould open raises unless it isLINEARandIGNORED. - A gradient map raises unless it is
LINEAR,IGNORED, holds texels and is one row high. Asked under every mode that lights the surface, becauseSHADE_LITreads one too. - A gradient map on a kind that is not
TOONraises, and so do corners that disagree about one. - A matcap raises unless it is
IGNORED. A matcap on a kind that is notMATCAPraises, and so do corners that disagree about one. - An alpha test outside zero to one, or not finite, raises on every worker count.
- A fog view that
FogView.validaterefuses raises before any fragment is drawn, on every worker count. - A depth mode that is none of the three raises in
set_depth_mode,clear_inside,DepthView,outline_maskandGpuRenderer.draw. A state underLOGARITHMIC_DEPTHwhose factor is not finite and positive raises, on both backends.