Skip to main content

CPU Rendering and PNG Output

Phase 3 renders the Phase 2 mesh to an RGBA PNG on the CPU. It uses an orthographic camera and a tile rasterizer. No GPU or Minecraft client is required. Textures are optional: without a resource pack the renderer uses flat block colors; with one it samples the pack textures. See docs/textures.md.

Pipeline

Camera

  • Orthographic projection.
  • Default preset: isometric_preview_v1, yaw 45°, elevation 30°.
  • Yaw follows the reference Litematic viewer: yaw 0 looks from +Z (south), yaw 45 looks from the south-west, and yaw 225 looks from the north-east. The default view therefore shows the south and west faces of the structure.
  • The camera looks at the center of the scene bounds.
  • The scene is fitted with padding; default padding is 16 px.
  • World positions are MeshPage.origin + page-local vertex.
  • Screen Y is flipped so image coordinates follow PNG top-left origin.
  • Smaller camera depth means closer to the camera.
The camera basis:

Mesh Inputs

The mesh builder splits the scene into 16x16x16 pages and can build pages in parallel (--threads, 0 uses all cores). Pages are merged in origin order, so output is byte-identical to a single-threaded build. Both the asset-backed and color-block paths resolve states into an immutable snapshot before workers start, because the mutable catalogs are not Sync. The rasterizer consumes the Phase 2 mesh:
  • page-local positions,
  • face normals,
  • straight RGBA block colors or texture tint,
  • texture id and per-vertex UVs,
  • render layer and shade flag,
  • per-vertex ambient occlusion.
Backface culling uses the camera forward vector:
Cross-plant quads are emitted as double-sided pairs, so one side survives the cull.

Rasterizer

  • The image is split into square tiles; default 64x64.
  • Each tile owns an RGBA f32 color buffer and an f64 depth buffer.
  • Triangles are rasterized with edge functions and barycentric weights.
  • Depth is interpolated linearly, which is correct for orthographic projection.
  • Opaque and cutout triangles run first and write depth.
  • Cutout fragments with alpha < 0.5 are discarded.
  • Textured fragments sample the texture, multiply by tint, face shade, and AO, then apply the same cutout/translucent rules.
  • Translucent triangles are sorted far-to-near per tile and blended without writing depth.
  • Solid backgrounds are filled before rasterization; transparent backgrounds start at [0, 0, 0, 0].
  • Final RGBA8 conversion clamps and rounds; fully transparent pixels are written as zero RGB.

Row spans

Each edge function is affine in screen x on a fixed scanline, so the rasterizer solves the three zero crossings to bound the row, then walks the two endpoints in with the exact per-pixel edge test. A row only visits the pixels inside the triangle instead of the whole bounding box. Barycentric weights, depth, and color interpolation still use the same expressions in the same order, so accepted fragments are bit-identical while most rejected pixels are never visited. pixelsTested counts the fragments that remain after this clipping.

Block entity NBT

The renderer never reads block entity contents, so mesh, bench, and render skip the TileEntities payload while decoding. inspect, validate, and stats keep it by default; pass --ignore-block-entity-nbt to skip it there too. Skipping does not allocate the payload, and if the NBT stream ends inside TileEntities, the block data parsed before it is kept and the missing block entities become an empty list. This makes container-heavy schematics renderable without decompressing gigabytes of book text.

Compact mesh attributes

The mesh stores fixed-point attributes instead of full floats. Texture coordinates are Q12 (i16); the builder emits UVs on a 1/16 or 1/32 grid for the tested material sets, so the round trip is exact. Ambient occlusion is one of four levels. Quads also share their color, normal, texture, and shade, which the builder already emits once per quad. Reconstruction uses the same float expressions as before, so rendered output is bit-identical.

Geometry table

Opaque and cutout geometry is projected once per quad and stored in a compact quad table (about 128 bytes per quad: four screen points, one color and shade pair, four AO levels, four Q12 UVs, texture id, layer). Triangles are formed only while a tile rasterizes a quad, so the render table is roughly half the size of storing two full triangles per quad. Translucent triangles are kept separately because they need a per-tile depth sort. Output stays byte-identical. Measured on the 370x370 fixture at 1024x1024 and supersample 2: peak memory dropped from 19.6 GB to 6.3 GB and render time from 13.5 s to 4.0 s; the PNG hash did not change.

Page streaming

When the projected quad table would be larger than the persistent tile buffers, the rasterizer streams mesh pages instead. It projects a chunk of pages, bins them to the tiles they touch, renders those tiles into buffers that stay resident for the whole image, and drops the chunk. Only one chunk of geometry is alive at a time. Output is byte-identical to the table path. Chunks are sized by projected quad count rather than page count, because page sizes differ by orders of magnitude between meshes; a page-count cap would spawn a worker scope per handful of fine-grained pages. Within a chunk the same scoped workers project contiguous page slices, bin the result, and rasterize the touched tiles, so projection no longer serializes the main thread while the workers wait. Slice and part order follow page order, which keeps the draw order and therefore the output deterministic. Measured on the test host at 1024x1024 and supersample 2: Both used to need the full per-quad table (13.5 GB and roughly 45 GB respectively). A small-scene equality test forces streaming and compares RGBA and statistics against the table path.

Triangle binning

Before rasterization, each triangle is inserted into every tile its screen-space bounding box covers. A tile only visits its own candidate list instead of the whole mesh. Bins preserve triangle order, so rendering stays deterministic.

Parallel tiles

Tiles are independent. The rasterizer takes work from a shared atomic cursor and spawns up to threads scoped workers; each worker owns its tile color and depth buffers and returns a finished tile. Results are sorted by tile index before assembly, so pixel output and statistics do not depend on scheduling order. threads = 0 chooses available_parallelism(). The default is 0.

Supersampling

supersample renders at width * factor by height * factor, then box-filters down to the requested output size. The filter works in premultiplied alpha so transparent edges do not darken. Supported factors are 1-4; default is 2. max_supersampled_pixels limits the internal buffer separately from the output pixel limit.

Lighting

Two profiles are available:

flat-v1

All face orientations use the same brightness. AO is ignored. This is the fastest and most uniform profile.

simple-ao-v1

light_dir is a fixed normalized direction [1, 2, 1] / sqrt(6). Vertex AO is computed in the mesh builder from the three neighboring cells at boundary corners. Interior and cross quads default to ao = 1.0. This is an approximation. It does not run Minecraft sky/block light propagation, shadows, or biome tinting. It is stable and deterministic.

Options

Validation rejects zero dimensions, elevation beyond ±89.9°, non-finite values, padding that leaves no drawable area, and outputs above the pixel limit. For CLI rendering flags, multi-view output naming, and options reference, see CLI Reference - render. Resource limits and --force-limits behavior are detailed in Limits and Security.

Benchmark

blockscope bench parses and meshes once, then renders and PNG-encodes --iterations times. It reports min/median/mean/max for render, PNG, and their sum, plus mesh and render counters, buffer sizes, and peak RSS on Linux (null on other platforms). For rasterizer-only scaling, use the synthetic example:
It prints single-thread, two-thread, and auto-thread timings. Reference run on a 12-core Windows host (tile_bench synthetic scene, 96,882 quads, 1024x1024 output, supersample 2, median of 3): 1 thread 300 ms, 2 threads 174 ms, auto threads 81 ms. Treat these as a scaling sanity check, not a CI threshold.

Render Statistics

RenderStatistics records:
  • output width/height, supersample factor, and internal dimensions;
  • tile count;
  • total triangles and culled backfaces;
  • opaque/cutout/translucent triangle counts;
  • textured triangle count;
  • pixels tested (fragments inside the clipped row spans) and pixels written.
Golden PNGs for cube, partial shapes, water, negative coordinates, AO, textured cube, stairs, cutout, glass, and chest live in fixtures/golden/render/. Regenerate them with UPDATE_GOLDEN=1 after an intentional renderer change. Textured goldens use project-owned synthetic assets only.

Level of Detail

--lod N (N in 2..=16) aggregates the parsed scene into N-sized cells before meshing. Each cell keeps the most frequent non-air state; ties use canonical state order and overlapping regions keep the later region. The result is an ordinary scene in LOD units, so meshing and rendering are unchanged. LOD is an approximation: block entities, entities, and pending ticks are dropped, and small details disappear. It is opt-in and off by default. Measured on the 9.4M-block stress fixture (211x211x211, 1024x1024, supersample 2, 12 threads, client jar): Aggregation is parallel over output rows and produces the same scene as the single-threaded path. On the test host, the LOD 2 pass for 000001 dropped from 0.63 s to 0.21 s; the scan stage alone dropped from 0.54 s to 0.07 s on 12 threads. LOD 8 for 000002 dropped from 1.2 s to 0.20 s. For oversized schematics, LOD 4 is the practical default; LOD 2 keeps more detail at a large memory cost. --lod auto starts with an exact mesh and falls back to 2, 4, 8, or 16 only when the quad budget does not fit. The budget is 80 percent of available memory divided by roughly 400 bytes per quad, so a file that can be rendered exactly is never degraded. On the 48 GB test host, 000003 (solid, 67M blocks) renders exactly in 7.5 s, while the porous 000001 falls back to LOD 2 in 16.4 s.

Known Limitations

  • Multi-view output writes separate PNGs; it does not compose a contact sheet.
  • Textures require a user-provided resource pack or client jar.
  • No mipmaps; nearest or bilinear sampling only.
  • No shadows, full light propagation, or emissive bloom.
  • Translucent sorting is per tile and approximate.
  • CPU-only; tiles are parallel and mesh pages are built in parallel, but PNG encoding runs on the calling thread.
  • Texture coordinates are Q12 fixed point; UVs outside [-8, 8) are clamped.
  • Color management is approximate; output is treated as sRGB.
  • Golden PNGs cover small synthetic scenes, not large real structures.
  • Performance thresholds still need a real workload and deployment target.

Determinism

Given the same Scene, Mesh, options, and renderer build, the RGBA buffer and PNG bytes are deterministic. Triangle order follows page/quad order, translucent sorting is stable, and the worker count does not change the output. Statistics are accumulated in tile order after workers join.