Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
edb6a2b
ae3d.exrpiz: decode OpenEXR PIZ blocks (Huffman, wavelet, value table)
paul-hammant Oct 28, 2026
7da5de4
ae3d.openexr: read PIZ blocks; a half's negative zero stays negative
paul-hammant Oct 28, 2026
c50e7c2
Tests: PIZ files written by OpenEXR, held to its decode bit for bit
paul-hammant Oct 28, 2026
059fd5f
Docs: PIZ in the environment-map formats, fixture provenance, licences
paul-hammant Oct 28, 2026
cae3faf
frames_against: name the one-step case the MSL tolerance exists for (…
paul-hammant Oct 28, 2026
dbe4825
Pick comments: crowds are picked; state the real exclusions (Nic 873-2)
paul-hammant Oct 28, 2026
b234f77
DoF: values are pixels of a 1080-line frame, not hundredths (Nic 874-4)
paul-hammant Oct 28, 2026
493e231
test_vk_msl: draw pick_quoted.frag and view_blit_quoted.frag as MSL (…
paul-hammant Oct 28, 2026
0b43402
Upscaler off frees its two RGBA16F targets at once, both backends (Ni…
paul-hammant Oct 28, 2026
1f0c275
Draw count: the upscaler's and OpenGL's DoF passes are not counted (N…
paul-hammant Oct 28, 2026
4e9a3ae
Environment map: refuse one that is not 2:1, naming its size (Nic 875-8)
paul-hammant Oct 28, 2026
8b19433
critique: hold the whole reflection under the feet too, so a reflecti…
paul-hammant Oct 28, 2026
084b203
changes: Nic's answers fragment (Nic 872-875)
paul-hammant Oct 28, 2026
cbce662
Tests: a Poly Haven PIZ EXR lights a frame on both backends
paul-hammant Oct 28, 2026
214001a
Merge main (#888, #889) into feat/exr-piz
paul-hammant Oct 30, 2026
bf7afe3
Merge main (#888, #889) into fix/nic-answers
paul-hammant Oct 30, 2026
5f2ede7
Merge remote-tracking branch 'origin/main' into fix/nic-answers
paul-hammant Oct 30, 2026
db962f9
Merge remote-tracking branch 'origin/main' into feat/exr-piz
paul-hammant Oct 30, 2026
58754f8
Merge #887 (EXR PIZ) into #886 (Nic's answers) as one PR
paul-hammant Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,17 @@ repository, unchanged.
- Model: © 2014 PixelMannen, [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/legalcode).
- Rigging and animation: © 2014 tomkranis, [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/legalcode).
- Conversion to glTF: © 2017 @AsoboStudio and @scurest, [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/legalcode).

## OpenEXR

`src/ae3d/exrpiz` (the PIZ block decoder of `ae3d.openexr`) follows the structure of the OpenEXR library's PIZ
decoder, written from the format's documentation and that library's source: the wavelet's level loop and its
Haar steps, the code-length table's run codes and the canonical code assignment. The library is BSD-3-Clause,
Copyright (c) Contributors to the OpenEXR Project; its notice is in that module's header. ae3d links none of it:
`tools/exr_reference/exrref.cpp` is a development tool against the official library, outside every build, that
wrote the PIZ test fixtures and the decodes they are held to.

## Poly Haven

`tests/fixtures/env/polyhaven/` holds two 1k HDRIs from [Poly Haven](https://polyhaven.com), published under
CC0 1.0 (public domain); see `tests/fixtures/env/PIZ.md` for the assets and their URLs.
12 changes: 12 additions & 0 deletions changes/2026-10-28-exr-piz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### OpenEXR PIZ files load as environment maps (#875 follow-up)

- `ae3d.exrpiz`, the PIZ block decoder of `ae3d.openexr`: a block's Huffman stream, the per-channel 2-D wavelet
(the 14-bit and the 16-bit forms) and the value table from its bitmap, back to the scanlines the other
compressions leave, for HALF and FLOAT channels, RGB, RGBA and a lone Y, any data window. Pure Aether, from the
format's documentation and the structure of the OpenEXR library's decoder (BSD-3 notice in the module header).
Every Poly Haven EXR is PIZ, so `engine_set_environment` and `AE3D_ENV` now take them.
- Still refused by name: PXR24, B44, B44A, DWAA, DWAB, tiled, deep and multipart files.
- `half_to_float` keeps a half's negative zero a negative zero (it was +0), so a decode equals OpenEXR's bit for bit.
- Tests: `test_envimage` holds 24 PIZ files written by the OpenEXR library, and two Poly Haven 1k assets, to
that library's own decode, bit for bit (a hash of the texel bits), and cuts and damages the small ones at every
byte; `tools/exr_reference/exrref.cpp` writes and dumps them (a development tool, not built, not linked).
19 changes: 19 additions & 0 deletions changes/2026-10-28-nic-answers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Small items from Nic's answers on #872-#875

- **The upscaler turned off frees its two RGBA16F targets at once**, on both
backends (they stayed until the frame was remade); `renderer_upscale_targets`
reports how many are alive.
- **The draw count has one rule**: a full-screen pass of the frame's own is not
counted. OpenGL no longer counts the upscaler's passes or the depth of
field's two (Vulkan reads its count before the post chain, so it never did).
- **An environment map that is not twice as wide as it is tall is refused**
(`.hdr` and `.exr`), the error naming its size; it was read and lit the scene
wrongly.
- `test_vk_msl` draws `pick_quoted.frag` and `view_blit_quoted.frag` as MSL and
holds them to their SPIR-V frames (`vkpipes.msl_made_for`).
- The critique holds the whole reflection under the zombie_street figure's feet
(0.21 as drawn, 0.54 at four times the strength) as well as the figure's part
of it, which barely moved.
- Comments: crowds are picked; the pick's exclusions are particles, surfaces
added over the frame, water and world text. DoF values are pixels of a
1080-line frame.
14 changes: 9 additions & 5 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,11 @@ place and `engine_environment_error()` says why.
The map is an equirectangular (latitude-longitude) Radiance `.hdr` (RGBE, flat
or run-length, the four mirror orientations of its resolution line) or OpenEXR
(`ae3d.envimage`, from the formats' descriptions, no library): scanline files,
HALF or FLOAT, R G B (A) or a lone Y, compression NONE, RLE, ZIPS or ZIP, lines
in any order and any data window. PIZ, PXR24, B44, B44A, DWAA, DWAB, tiled,
deep and multipart files are refused by name. It is placed as a painted sky
HALF or FLOAT, R G B (A) or a lone Y, compression NONE, RLE, ZIPS, ZIP or PIZ
(`ae3d.exrpiz`, what Poly Haven's EXRs are), lines in any order and any data
window. PXR24, B44, B44A, DWAA, DWAB, tiled, deep and multipart files are
refused by name, and so is a map that is not twice as wide as it is tall (a cube
cross, a mirror ball; the error names its size). It is placed as a painted sky
is: the top row is the zenith and the middle column looks along +x. Its light
is the engine's radiance times `intensity`: 1 is the file's own values, and
exposure handles the rest.
Expand Down Expand Up @@ -1517,8 +1519,10 @@ there, not what a ray at the model's bounds would meet:
columns read and not changed. The crowd's impostor tier is drawn as the
quad it is.

A particle's points, a surface added over the frame (flames) and the water
are no one's: a pick sees through them. The pass runs only in a frame that was
A particle's points, a surface added over the frame (flames), the water and
world text (drawn after the pass) are no one's: a pick sees through them.
Crowds are picked (above). The water stays out on purpose: a boat or a
fishing cursor asks `water.simulation_height_at` on the CPU. The pass runs only in a frame that was
asked, before the scene pass, in the same command buffer; a frame that asks
nothing has no target, no pipeline and no draw. It draws at the scene's size
(the render scale applies), one sample, with the frame's jitter: the world
Expand Down
6 changes: 6 additions & 0 deletions scripts/frames_against.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
# pixel of the spheres, 77,80) a step off the SPIR-V's, so
# AE3D_WITHIN=1 AE3D_OVER=2.
#
# The known case, so nobody widens this later without knowing why: with
# AE3D_MSL=1 at 4x, seven frames differ by one step in one channel at the
# spheres' pixel (77,80). That is the whole of what 1 step on 2 channels
# excuses, and it is opt-in only; a byte-for-byte run (no AE3D_WITHIN) stays
# the default and the bar for every change that is not the MSL.
#
# Exits 0 when every frame matches, 1 naming those that do not, 2 when
# something could not be built or drawn. Needs a GPU (a software rasteriser
# draws it too, slowly); the frames are kept under build/frames_against/ to
Expand Down
5 changes: 4 additions & 1 deletion src/ae3d/core/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -1711,7 +1711,10 @@ material_set_texture_id(m: *Material, id: int) {
// instanced); `depth` is the scene's own, 0 at the near plane and 1 at the
// far; `position` is that depth unprojected through the camera, the surface
// point the pixel shows. A particle, a surface added over the frame and the
// water are no one's: a pick sees through them.
// water are no one's: a pick sees through them, as it does through world text
// (drawn after the pass). Crowds ARE picked. The water stays out on purpose: a
// boat or a fishing cursor asks the water's height at a point on the CPU
// (water.simulation_height_at), and the surface under it is not a thing to hit.
struct Pick {
hit: bool
model_id: int
Expand Down
32 changes: 25 additions & 7 deletions src/ae3d/envimage/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@
// envimage.dispose(img) // hdrimage.dispose
//
// Both formats are read as the equirectangular (latitude-longitude) map they
// nearly always hold: the width twice the height, the top row the zenith, the
// left and right edges the same meridian. A map that is not twice as wide as
// it is tall is still read -- the renderer maps its columns to longitude and
// its rows to latitude whatever the proportions -- but a cube cross or a
// mirror ball is not an equirectangular map and would light a scene wrongly;
// that is the caller's to know.
// hold: the width twice the height, the top row the zenith, the left and
// right edges the same meridian. A map that is not twice as wide as it is
// tall (a cube cross, a mirror ball, a crop) is not an equirectangular map and
// would light a scene wrongly, so it is refused, by either format's decoder,
// with an error that names its size (Nic 875-8).
import std.string
import ae3d.blob
import ae3d.core
Expand All @@ -21,12 +20,19 @@ import ae3d.radiance
import ae3d.openexr
import std.mem

exports (load, from_memory, dispose, error, half_rows, float_rows, lod_for_width)
exports (load, from_memory, decode_any, dispose, error, half_rows, float_rows, lod_for_width)

error() -> string { return hdrimage.error() }
dispose(img: ptr) { hdrimage.dispose(img) }

from_memory(data: @keep ptr, length: int) -> ptr {
return two_to_one(decode_any(data, length))
}

// The same without the shape check: whatever picture the bytes are, of any
// proportions. For the decoders' own tests, which read pictures of 8 by 20;
// an environment map comes in by `from_memory` or `load`.
decode_any(data: @keep ptr, length: int) -> ptr {
if data == null || length <= 0 {
hdrimage.set_error("empty environment map")
return null
Expand All @@ -37,6 +43,18 @@ from_memory(data: @keep ptr, length: int) -> ptr {
return null
}

// `img` when it is as wide as two of its height, else refused (freed, null)
// with the size in the error.
two_to_one(img: @into ptr) -> ptr {
if img == null { return null }
w = hdrimage.width(img)
h = hdrimage.height(img)
if w == 2 * h { return img }
hdrimage.dispose(img)
hdrimage.set_error("the environment map is ${w} x ${h}: an equirectangular map is twice as wide as it is tall (2:1)")
return null
}

load(path: string) -> ptr {
if path == "" {
hdrimage.set_error("empty path")
Expand Down
Loading
Loading