From 6c690c5db6733d8ac4d9a31675bf8ef2bac5c032 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 19:10:06 +0300 Subject: [PATCH 1/2] render3d + lab: bakes as PNGs, and impostors, the sky's light and the carpet read from their bakes ludic.lab: lab_png_write / lab_png_write_from (raw 8-bit, 1-4 channels, stored deflate) in png_write.ludic, importable alone with its own LabPngState; png_convert.ludic's previews of float textures (R32F min..max, RG16F x255, HDR x/(1+x) + sRGB); lab_ppm_to_png on the same encoder. render3d: bake_load.ludic - impostor_from_baked / impostor_source / impostor_refill (a fog re-open reads the bake), sky_baked_in and sky_precompute trying the bake at the start yaw (sky_compute is the convolution, and sky_ibl_bytes always uses it), carpet_from_baked / carpet_bytes / carpet_finish, bake_part_count / _len / _off. Compile-only: nothing run. Co-Authored-By: Claude Opus 5.5 --- changes/bake-images.md | 11 ++ packages/ludic.lab/README.md | 2 + packages/ludic.lab/index.ludic | 2 + packages/ludic.lab/png.ludic | 101 +-------------- packages/ludic.lab/png_convert.ludic | 71 +++++++++++ packages/ludic.lab/png_write.ludic | 133 ++++++++++++++++++++ packages/ludic.lab/tests/lab_test.ludic | 26 +++- packages/ludic.render3d/bake_load.ludic | 108 ++++++++++++++++ packages/ludic.render3d/bake_parts.ludic | 16 ++- packages/ludic.render3d/env.ludic | 4 + packages/ludic.render3d/fog_impostors.ludic | 5 +- packages/ludic.render3d/r3d.ludic | 1 + packages/ludic.render3d/render.ludic | 2 +- packages/ludic.render3d/scatter.ludic | 14 ++- packages/ludic.render3d/sky.ludic | 7 ++ 15 files changed, 396 insertions(+), 107 deletions(-) create mode 100644 changes/bake-images.md create mode 100644 packages/ludic.lab/png_convert.ludic create mode 100644 packages/ludic.lab/png_write.ludic create mode 100644 packages/ludic.render3d/bake_load.ludic diff --git a/changes/bake-images.md b/changes/bake-images.md new file mode 100644 index 00000000..7a115526 --- /dev/null +++ b/changes/bake-images.md @@ -0,0 +1,11 @@ +bump: minor +type: feature +**Bakes you can look at, and three more of the renderer's textures read from one.** `ludic.lab` writes +raw 8-bit pixels (1 to 4 channels) as a PNG (`lab_png_write`, `png_write.ludic`, importable alone with its +own `LabPngState`) and turns float textures into honest previews (`png_convert.ludic`: R32F min..max as +grey, RG16F as red and green x255, HDR RGBA16F as x/(1+x) then sRGB). `ludic.render3d` takes three bakes +the game names (`bake_load.ludic`) and makes each as before when one is missing or stale: an impostor's +atlases (`impostor_from_baked`, and a fog opening past its cards reads the bake again instead of +painting), the sky's image-based light at the start yaw per prefilter width (`sky_baked_in`; any other +turn of the sky is convolved), and the grass carpet (`carpet_from_baked`, `carpet_bytes`). +`impostor_from_bytes` returns null, keeping nothing, when the bytes are not the impostor's shape. diff --git a/packages/ludic.lab/README.md b/packages/ludic.lab/README.md index f4396bea..34ca8069 100644 --- a/packages/ludic.lab/README.md +++ b/packages/ludic.lab/README.md @@ -61,6 +61,8 @@ showing its own models - adds them from its own module with `def LabScenes []long { - let t = new []long - let poly: long = 3988292384 # 0xEDB88320: eight hex digits would be a 32-bit pattern - for n in 0 .. 256 { - var c = long(n) - for k in 0 .. 8 { - if (c & long(1)) != long(0) { c = (c >> long(1)) ^ poly } else { c = c >> long(1) } - } - push(t, c) - } - return t -} -# CRC-32 of out[from .. from + n) -function lp_crc(lab_st: mut LabState, out: []byte, from: int, n: int) -> long { - let mask: long = 4294967295 - var c = mask - for i in from .. from + n { - let k = int((c ^ long(out[i])) & long(255)) - c = lab_st.lp_table[k] ^ (c >> long(8)) - } - return c ^ mask -} -function lp_put32(out: []byte, at: int, v: long) -> void { - out[at] = int((v >> long(24)) & long(255)) - out[at + 1] = int((v >> long(16)) & long(255)) - out[at + 2] = int((v >> long(8)) & long(255)) - out[at + 3] = int(v & long(255)) -} -function lp_tag(out: []byte, at: int, tag: string) -> void { - for i in 0 .. 4 { out[at + i] = tag[i] } -} # a PPM's header: "P6", width, height, maxval, each after white space; returns the pixel offset function lp_ppm_header(lab_st: mut LabState, d: []byte) -> int { var i = 2 @@ -60,68 +27,10 @@ export function lab_ppm_to_png(lab_st: mut LabState, ppm: string, png: string) - let px = lp_ppm_header(lab_st, d) let w = lab_st.lp_w let h = lab_st.lp_h - let row = w * 3 + 1 - let raw = row * h - let blocks = (raw + 65534) / 65535 - let idat = 2 + blocks * 5 + raw + 4 - let total = 8 + 25 + 12 + idat + 12 - let out = buffer(total) - let sig = [137, 80, 78, 71, 13, 10, 26, 10] - for i in 0 .. 8 { out[i] = sig[i] } - var at = 8 - lp_put32(out, at, long(13)) - lp_tag(out, at + 4, "IHDR") - lp_put32(out, at + 8, long(w)) - lp_put32(out, at + 12, long(h)) - out[at + 16] = 8 - out[at + 17] = 2 - out[at + 18] = 0 - out[at + 19] = 0 - out[at + 20] = 0 - lp_put32(out, at + 21, lp_crc(lab_st, out, at + 4, 17)) - at += 25 - lp_put32(out, at, long(idat)) - lp_tag(out, at + 4, "IDAT") - var o = at + 8 - out[o] = 120 - out[o + 1] = 1 - o += 2 - var a = 1 - var b = 0 - var left = raw - var k = 0 # the byte of the raw stream being written - while left > 0 { - let n = Math.min(left, 65535) - left -= n - var fin = 0 - if left == 0 { fin = 1 } - out[o] = fin - out[o + 1] = n & 255 - out[o + 2] = (n >> 8) & 255 - out[o + 3] = (~n) & 255 - out[o + 4] = ((~n) >> 8) & 255 - o += 5 - for j in 0 .. n { - let y = k / row - let x = k % row - var v = 0 - if x > 0 { v = d[px + y * w * 3 + x - 1] } - out[o] = v - a = (a + v) % 65521 - b = (b + a) % 65521 - o += 1 - k += 1 - } - } - lp_put32(out, o, long(b) * long(65536) + long(a)) - o += 4 - lp_put32(out, o, lp_crc(lab_st, out, at + 4, o - at - 4)) - o += 4 - lp_put32(out, o, long(0)) - lp_tag(out, o + 4, "IEND") - lp_put32(out, o + 8, lp_crc(lab_st, out, o + 4, 4)) - o += 12 - if not Fs.write_bytes(png, out, o) { return false } + if px + w * h * 3 > len(d) { return false } + let out = buffer(lp_png_size(w, h, 3)) + let n = lp_encode(lab_st.lp_table, w, h, 3, d, px, out) + if not Fs.write_bytes(png, out, n) { return false } Fs.remove(ppm) return true } diff --git a/packages/ludic.lab/png_convert.ludic b/packages/ludic.lab/png_convert.ludic new file mode 100644 index 00000000..84b43e47 --- /dev/null +++ b/packages/ludic.lab/png_convert.ludic @@ -0,0 +1,71 @@ +# png_convert.ludic - previews of textures that are not 8-bit, as 8-bit pixels for lab_png_write. Each says +# its mapping, because a preview is only honest when you know what a grey level means. Little-endian +# samples as a GPU stores them; nothing here is ever read back into a texture. + +# the 32-bit float at src[at] +export function lab_f32_at(src: []byte, at: int) -> float { + let bits = (src[at] & 255) | ((src[at + 1] & 255) << 8) | ((src[at + 2] & 255) << 16) | ((src[at + 3] & 255) << 24) + return float_from_bits(bits) +} +# the 16-bit half float at src[at]; infinities and NaNs read as the largest half (65504) +export function lab_f16_at(src: []byte, at: int) -> float { + let h = (src[at] & 255) | ((src[at + 1] & 255) << 8) + let e = (h >> 10) & 31 + let m = float(h & 1023) + var v = 65504.0 + if e == 0 { v = m * Math.pow(2.0, -24.0) } + if e > 0 and e < 31 { v = (1.0 + m / 1024.0) * Math.pow(2.0, float(e - 15)) } + if (h & 32768) != 0 { return -v } + return v +} +function lp_byte(x: float) -> int { return int(Math.clamp(x * 255.0 + 0.5, 0.0, 255.0)) } + +# n R32F samples at src[at] as grey: the smallest value black, the largest white, linear between +# (a flat field is black). The range is the image's own, so two previews are not on one scale +@alloc_ok("a bake's preview, once") +export function lab_px_r32f_norm(src: []byte, at: int, n: int) -> []byte { + var lo = lab_f32_at(src, at) + var hi = lo + for i in 0 .. n { + let v = lab_f32_at(src, at + i * 4) + lo = Math.min(lo, v) + hi = Math.max(hi, v) + } + let out = buffer(n) + var span = hi - lo + if span <= 0.0 { span = 1.0 } + for i in 0 .. n { out[i] = lp_byte((lab_f32_at(src, at + i * 4) - lo) / span) } + return out +} + +# n RG16F samples at src[at] as RGB: red and green each x255 clamped to 0..1, blue 0 (a table of two +# numbers in 0..1, such as the BRDF lookup or a distance and a mask) +@alloc_ok("a bake's preview, once") +export function lab_px_rg16f(src: []byte, at: int, n: int) -> []byte { + let out = buffer(n * 3) + for i in 0 .. n { + out[i * 3] = lp_byte(lab_f16_at(src, at + i * 4)) + out[i * 3 + 1] = lp_byte(lab_f16_at(src, at + i * 4 + 2)) + out[i * 3 + 2] = 0 + } + return out +} + +# sRGB's encoding of a linear value in 0..1 +function lp_srgb(x: float) -> float { + if x <= 0.0031308 { return x * 12.92 } + return 1.055 * Math.pow(x, 1.0 / 2.4) - 0.055 +} +# n RGBA16F samples at src[at] (linear HDR light) as RGB: each channel x/(1+x) (Reinhard: 1.0 lands at +# half and nothing clips), then sRGB-encoded; alpha dropped. No exposure is applied +@alloc_ok("a bake's preview, once") +export function lab_px_rgba16f_hdr(src: []byte, at: int, n: int) -> []byte { + let out = buffer(n * 3) + for i in 0 .. n { + for c in 0 .. 3 { + let x = Math.max(lab_f16_at(src, at + i * 8 + c * 2), 0.0) + out[i * 3 + c] = lp_byte(lp_srgb(x / (1.0 + x))) + } + } + return out +} diff --git a/packages/ludic.lab/png_write.ludic b/packages/ludic.lab/png_write.ludic new file mode 100644 index 00000000..af01ba8e --- /dev/null +++ b/packages/ludic.lab/png_write.ludic @@ -0,0 +1,133 @@ +# png_write.ludic - raw 8-bit pixels (1 to 4 channels) as a PNG anything opens: filter 0 on every row, +# deflate's stored blocks (no zlib here), the CRCs and the Adler-32. Needs nothing else of the lab, so a +# program with a lab of its own imports this file alone: import "ludic.lab/png_write.ludic" + +export state LabPngState { + table: []long = lp_crc_table() +} + +# the CRC-32 table, made once with a state +function lp_crc_table() -> []long { + let t = new []long + let poly: long = 3988292384 # 0xEDB88320: eight hex digits would be a 32-bit pattern + for n in 0 .. 256 { + var c = long(n) + for k in 0 .. 8 { + if (c & long(1)) != long(0) { c = (c >> long(1)) ^ poly } else { c = c >> long(1) } + } + push(t, c) + } + return t +} +# CRC-32 of out[from .. from + n) +function lp_crc_of(table: []long, out: []byte, from: int, n: int) -> long { + let mask: long = 4294967295 + var c = mask + for i in from .. from + n { + let k = int((c ^ long(out[i])) & long(255)) + c = table[k] ^ (c >> long(8)) + } + return c ^ mask +} +function lp_put32(out: []byte, at: int, v: long) -> void { + out[at] = int((v >> long(24)) & long(255)) + out[at + 1] = int((v >> long(16)) & long(255)) + out[at + 2] = int((v >> long(8)) & long(255)) + out[at + 3] = int(v & long(255)) +} +function lp_tag(out: []byte, at: int, tag: string) -> void { + for i in 0 .. 4 { out[at + i] = tag[i] } +} +# PNG's colour type for 1..4 channels: grey, grey + alpha, RGB, RGBA +function lp_colour_type(channels: int) -> int { + if channels == 1 { return 0 } + if channels == 2 { return 4 } + if channels == 3 { return 2 } + return 6 +} + +# the header, then the pixels, then the end, into one buffer; the length written, or -1 +@alloc_ok("a bake's preview or a lab shot: one image written, once") +function lp_encode(table: []long, w: int, h: int, channels: int, px: []byte, at: int, out: []byte) -> int { + let sig = [137, 80, 78, 71, 13, 10, 26, 10] + for i in 0 .. 8 { out[i] = sig[i] } + lp_put32(out, 8, long(13)) + lp_tag(out, 12, "IHDR") + lp_put32(out, 16, long(w)) + lp_put32(out, 20, long(h)) + out[24] = 8 + out[25] = lp_colour_type(channels) + out[26] = 0 + out[27] = 0 + out[28] = 0 + lp_put32(out, 29, lp_crc_of(table, out, 12, 17)) + let o = lp_idat(table, w, h, channels, px, at, out, 33) + lp_put32(out, o, long(0)) + lp_tag(out, o + 4, "IEND") + lp_put32(out, o + 8, lp_crc_of(table, out, o + 4, 4)) + return o + 12 +} +# the IDAT chunk at `at0`: every row behind a filter byte of 0, in stored blocks; where it ends +function lp_idat(table: []long, w: int, h: int, channels: int, px: []byte, at: int, out: []byte, at0: int) -> int { + let stride = w * channels + let row = stride + 1 + let raw = row * h + let blocks = (raw + 65534) / 65535 + lp_put32(out, at0, long(2 + blocks * 5 + raw + 4)) + lp_tag(out, at0 + 4, "IDAT") + var o = at0 + 8 + out[o] = 120 + out[o + 1] = 1 + o += 2 + var a = 1 + var b = 0 + var left = raw + var k = 0 # the byte of the raw stream being written + while left > 0 { + let n = Math.min(left, 65535) + left -= n + var fin = 0 + if left == 0 { fin = 1 } + out[o] = fin + out[o + 1] = n & 255 + out[o + 2] = (n >> 8) & 255 + out[o + 3] = (~n) & 255 + out[o + 4] = ((~n) >> 8) & 255 + o += 5 + for j in 0 .. n { + let x = k % row + var v = 0 + if x > 0 { v = px[at + (k / row) * stride + x - 1] & 255 } + out[o] = v + a = (a + v) % 65521 + b = (b + a) % 65521 + o += 1 + k += 1 + } + } + lp_put32(out, o, long(b) * long(65536) + long(a)) + o += 4 + lp_put32(out, o, lp_crc_of(table, out, at0 + 4, o - at0 - 4)) + return o + 4 +} + +# the bytes a PNG of this size takes, stored +function lp_png_size(w: int, h: int, channels: int) -> int { + let raw = (w * channels + 1) * h + return 8 + 25 + 12 + 2 + ((raw + 65534) / 65535) * 5 + raw + 4 + 12 +} + +# w x h pixels of `channels` 8-bit samples each (1 grey, 2 grey + alpha, 3 RGB, 4 RGBA), rows top down, +# starting at px[at], written as a PNG at `path` +@alloc_ok("a bake's preview or a lab shot: one image written, once") +export function lab_png_write_from(lab_png_st: LabPngState, path: string, w: int, h: int, channels: int, px: []byte, at: int) -> bool { + if channels < 1 or channels > 4 or w <= 0 or h <= 0 or at + w * h * channels > len(px) { return false } + let out = buffer(lp_png_size(w, h, channels)) + let n = lp_encode(lab_png_st.table, w, h, channels, px, at, out) + let ok = Fs.write_bytes(path, out, n) + free(out) + return ok +} +export function lab_png_write(lab_png_st: LabPngState, path: string, w: int, h: int, channels: int, px: []byte) -> bool { + return lab_png_write_from(lab_png_st, path, w, h, channels, px, 0) +} diff --git a/packages/ludic.lab/tests/lab_test.ludic b/packages/ludic.lab/tests/lab_test.ludic index 48f9b4fa..ffa25792 100644 --- a/packages/ludic.lab/tests/lab_test.ludic +++ b/packages/ludic.lab/tests/lab_test.ludic @@ -1,4 +1,4 @@ -# lab_test.ludic - what the lab computes without a window: the PNG it keeps a shot as, the cameras +# lab_test.ludic - what the lab computes without a window: the PNGs it writes (a shot, raw pixels), the cameras # a scene names, and the scenes a program adds import "ludic.lab" program LabTest { @@ -35,6 +35,30 @@ program LabTest { expect(not Fs.exists(`{dir}/lab_test.ppm`)) } + test "raw pixels become a PNG of their channel count" (lab_png_st: LabPngState) { + let dir = Os.temp_dir() + let px = buffer(3 * 2 * 4) + for i in 0 .. 24 { px[i] = i * 10 } + expect(lab_png_write(lab_png_st, `{dir}/lab_test_rgba.png`, 3, 2, 4, px)) + let p = Fs.read_bytes(`{dir}/lab_test_rgba.png`) + expect_eq(p[19], 3) # width + expect_eq(p[25], 6) # colour type RGBA + expect_eq(len(p), lp_png_size(3, 2, 4)) + expect(not lab_png_write(lab_png_st, `{dir}/lab_test_short.png`, 9, 9, 4, px)) + } + + test "a half float and a float read as the numbers they store" { + let b = buffer(8) + b[0] = 0; b[1] = 60 # half 1.0 = 0x3C00 + b[2] = 0; b[3] = 192 # half -2.0 = 0xC000 + b[4] = 0; b[5] = 0; b[6] = 128; b[7] = 63 # float 1.0 = 0x3F800000 + expect_near(lab_f16_at(b, 0), 1.0, 0.0001) + expect_near(lab_f16_at(b, 2), -2.0, 0.0001) + expect_near(lab_f32_at(b, 4), 1.0, 0.0001) + let g = lab_px_rgba16f_hdr(b, 0, 1) # 1.0 -> 0.5 -> sRGB 188 + expect_eq(g[0] & 255, 188) + } + test "a camera at a point looks back at it" (lab_st: mut LabState) { lab_shots_reset(lab_st) lab_shot_at(lab_st, "front", 0.0, 1.0, 0.0, 10.0, 0.0, 0.0) diff --git a/packages/ludic.render3d/bake_load.ludic b/packages/ludic.render3d/bake_load.ludic new file mode 100644 index 00000000..7d3188e1 --- /dev/null +++ b/packages/ludic.render3d/bake_load.ludic @@ -0,0 +1,108 @@ +# bake_load.ludic — the game names where a bake of the renderer's work lives, and the renderer takes the +# textures from it when it is current and makes them as it always has when it is not: an impostor's +# atlases (and again when a fog opens past its cards), the sky's image-based light, the grass carpet. + +# ---- a bundle's parts, for whoever writes a preview of one (bake_pack's layout) ---- +function bake_part_count(data: []byte) -> int { + if data == null or len(data) < 4 { return 0 } + return Vk.get_i32(data_of(data), 0) +} +function bake_part_len(data: []byte, i: int) -> int { return Vk.get_i32(data_of(data), 4 + i * 4) } +# where part i's bytes begin in the bundle +function bake_part_off(data: []byte, i: int) -> int { + var at = 4 + bake_part_count(data) * 4 + for k in 0 .. i { at += bake_part_len(data, k) } + return at +} + +# ---- an impostor ---- +# where an impostor's bake lives (the game's name for it), whether or not its atlases came from it +function impostor_source(im: Impostor, path: string, key: string, version: int) -> void { + im.src_path = intern(path) + im.src_key = intern(key) + im.src_version = version +} + +# an impostor of this shape from its bake, or null (missing, stale, or not this shape) for the caller to paint +@alloc_ok("a bake read once at a map load") +function impostor_from_baked(render3d_st: mut Render3dState, model: Model, tiles: int, tw: int, th: int, path: string, key: string, version: int) -> Impostor { + let b = r3d_baked_read(path, key, version) + if b == null { return null } + let im = impostor_from_bytes(render3d_st, model, tiles, tw, th, b) + free(b) + if im == null { return null } + impostor_source(im, path, key, version) + im.src_ok = true + return im +} +# the atlases filled from the bake again (a fog opening past the cards); false, and none kept, when it cannot be +@alloc_ok("a bake read once when a fog opens") +function impostor_refill(render3d_st: mut Render3dState, im: Impostor) -> bool { + let b = r3d_baked_read(im.src_path, im.src_key, im.src_version) + if b == null { return false } + let ok = impostor_fill(render3d_st, im, b) + free(b) + if not ok { + gpu_tex_free(render3d_st, im.albedo); gpu_tex_free(render3d_st, im.normal) + im.albedo = 0; im.normal = 0 + } + return ok +} + +# ---- the sky's image-based light ---- +# the directory the sky's bakes are in, under the key and version they were baked as: before r3d_init. +# A bake is one HDRI at the start yaw at one prefilter width; any other turn of the sky is convolved +function sky_baked_in(render3d_st: mut Render3dState, dir: string, key: string, version: int) -> void { + render3d_st.sky_bake_dir = intern(dir) + render3d_st.sky_bake_key = intern(key) + render3d_st.sky_bake_version = version +} +# the HDRI's name without its directory or ".hdr": what the sky's bakes are named by +@alloc_ok("a bake's name, made at a load") +function sky_hdri_name(render3d_st: Render3dState) -> string { + let p = render3d_st.r3d_sky_path + if p == null { return SKY_DEFAULT_HDRI } + var from = 0 + for i in 0 .. len(p) { if p[i] == 47 { from = i + 1 } } + var to = len(p) + if to - from > 4 and p[to - 4] == 46 { to -= 4 } + return p[from .. to] +} +# /_y_w.bin, the file one bake is +@alloc_ok("a bake's name, made at a load") +function sky_bake_file(render3d_st: Render3dState, w: int) -> string { + let deg = int(Math.round(Math.rad_to_deg(render3d_st.sky_start_yaw))) + return `{render3d_st.sky_bake_dir}/{sky_hdri_name(render3d_st)}_y{deg}_w{w}.bin` +} +# the IBL set from its bake when the sky is at the start yaw and a bake of this width is current +@alloc_ok("a bake read once at a load or a new light quality") +function sky_ibl_try_baked(render3d_st: mut Render3dState) -> bool { + if len(render3d_st.sky_bake_dir) == 0 or render3d_st.sky_yaw != render3d_st.sky_start_yaw { return false } + let path = sky_bake_file(render3d_st, render3d_st.sky_prefilter_w) + let b = r3d_baked_read(path, render3d_st.sky_bake_key, render3d_st.sky_bake_version) + var ok = false + if b != null { ok = sky_ibl_from_bytes(render3d_st, b) } + if b != null { free(b) } + if not ok and not render3d_st.sky_bake_said { sky_say_missing(path) } + if not ok { render3d_st.sky_bake_said = true } + return ok +} +function sky_say_missing(path: string) -> void { print(`bake: {path} missing or stale - the sky's light made at run time (ludic bake)`) } + +# ---- the grass carpet ---- +function carpet_bytes(render3d_st: mut Render3dState, tex: int) -> []byte { return bake_pack(render3d_st, bake_list1(tex)) } +# the carpet texture (RGBA8, res square) from its bake, finished as carpet_bake finishes it; 0 when missing +@alloc_ok("a bake read once at a map load") +function carpet_from_baked(render3d_st: mut Render3dState, path: string, key: string, version: int, res: int) -> int { + let b = r3d_baked_read(path, key, version) + if b == null { return 0 } + let was = render3d_st.gvk_tag + render3d_st.gvk_tag = VKM_SCATTER + let tex = tex_target(render3d_st, res, res, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + let ok = bake_unpack(render3d_st, b, bake_list1(tex)) + free(b) + if ok { carpet_finish(render3d_st, tex) } else { gpu_tex_free(render3d_st, tex) } + render3d_st.gvk_tag = was + if ok { return tex } + return 0 +} diff --git a/packages/ludic.render3d/bake_parts.ludic b/packages/ludic.render3d/bake_parts.ludic index 1e501c63..dcea578f 100644 --- a/packages/ludic.render3d/bake_parts.ludic +++ b/packages/ludic.render3d/bake_parts.ludic @@ -36,6 +36,12 @@ function bake_unpack(render3d_st: mut Render3dState, data: []byte, texs: words) return true } +@alloc_ok("a bake's list of textures, once") +function bake_list1(a: int) -> words { + let w = words(1) + w[0] = a + return w +} @alloc_ok("a bake's list of textures, once") function bake_list2(a: int, b: int) -> words { let w = words(2) @@ -52,13 +58,15 @@ function bake_list3(a: int, b: int, c: int) -> words { # ---- an impostor: its albedo and normal atlases (level 0; mips are made after) ---- function impostor_bytes(render3d_st: mut Render3dState, im: Impostor) -> []byte { return bake_pack(render3d_st, bake_list2(im.albedo, im.normal)) } -# an impostor with the bake's shape (model, tiles and tile size as impostor_bake takes them) from bytes +# an impostor with the bake's shape (model, tiles and tile size as impostor_bake takes them) from bytes; +# null, and nothing kept, when the bytes are not that shape function impostor_from_bytes(render3d_st: mut Render3dState, model: Model, tiles: int, tw: int, th: int, data: []byte) -> Impostor { let im = new Impostor im.tiles = tiles; im.radius = model.radius * 1.02; im.height = model.height im.model = model; im.tw = tw; im.th = th; im.flower = render3d_st.sc_bake_flower - impostor_fill(render3d_st, im, data) - return im + if impostor_fill(render3d_st, im, data) { return im } + gpu_tex_free(render3d_st, im.albedo); gpu_tex_free(render3d_st, im.normal) + return null } # an impostor's atlases made and filled from bytes (at a load, or when a fog opens past its cards) @@ -96,7 +104,7 @@ function terrain_shadow_from_bytes(render3d_st: mut Render3dState, data: []byte) # ---- the sky's image-based lighting: irradiance, the prefiltered levels, the BRDF table ---- # for the HDRI already loaded and the yaw already set (the yaw is part of what a bake is keyed on) function sky_ibl_bytes(render3d_st: mut Render3dState) -> []byte { - sky_precompute(render3d_st) + sky_compute(render3d_st) # never from an older bake return bake_pack(render3d_st, bake_list3(render3d_st.sky_irradiance, render3d_st.sky_prefilter, render3d_st.sky_brdf)) } diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index a6188f2f..845bce71 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -705,6 +705,10 @@ export state Render3dState { sky_p_pre: int = 0 sky_p_brdf: int = 0 sky_prefilter_w: int = 512 + sky_bake_dir: string = "" # where the sky's IBL bakes are (sky_baked_in, bake_load.ludic); "" = none + sky_bake_key: string = "" + sky_bake_version: int = 0 + sky_bake_said: bool = false # a missing bake said once STREAM_MAX_CHUNKS: int = 4096 stream_all: []Stream = null stream_cap_read: bool = false diff --git a/packages/ludic.render3d/fog_impostors.ludic b/packages/ludic.render3d/fog_impostors.ludic index 53b6adfa..38029bc5 100644 --- a/packages/ludic.render3d/fog_impostors.ludic +++ b/packages/ludic.render3d/fog_impostors.ludic @@ -1,5 +1,5 @@ # fog_impostors.ludic — a layer's impostor atlases are let go while the fog wall is nearer than its -# cards begin (the layer's `near`), and baked again when the fog opens past it. Inside the wall every +# cards begin (the layer's `near`), and baked again (or read again from its bake) when the fog opens past it. Inside the wall every # instance is a mesh, and the meshes' LOD2 cast its shadows, so a card is neither drawn nor cast. # does the wall let any of this layer's instances be drawn as a card? @@ -22,7 +22,8 @@ function fog_impostor(render3d_st: mut Render3dState, l: Layer) -> void { let t0 = gl_now_us() let was = render3d_st.gvk_tag render3d_st.gvk_tag = VKM_IMPOSTOR - impostor_paint(render3d_st, im) + # a baked impostor is read again rather than drawn again (bake_load.ludic) + if not (im.src_ok and impostor_refill(render3d_st, im)) { impostor_paint(render3d_st, im) } render3d_st.gvk_tag = was render3d_st.fog_imp_rebake_us = render3d_st.fog_imp_rebake_us + (gl_now_us() - t0) render3d_st.fog_imp_rebaked += 1 diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index cf69746e..b20f3aec 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -59,4 +59,5 @@ import "water.ludic" import "streamline.ludic" import "render.ludic" import "bake_parts.ludic" +import "bake_load.ludic" import "safe_api.ludic" diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index 663191ae..942e7a9f 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -134,7 +134,7 @@ function r3d_load_step(render3d_st: mut Render3dState, i: int) -> bool { var sky = render3d_st.r3d_sky_path var made: string = null if sky == null { - made = render3d_st.r3d_assets + "/hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr" + made = render3d_st.r3d_assets + "/hdri/" + SKY_DEFAULT_HDRI + ".hdr" sky = made } let got = sky_load(render3d_st, sky) diff --git a/packages/ludic.render3d/scatter.ludic b/packages/ludic.render3d/scatter.ludic index 616ab527..6837d86c 100644 --- a/packages/ludic.render3d/scatter.ludic +++ b/packages/ludic.render3d/scatter.ludic @@ -16,7 +16,11 @@ property Impostor { model: Model, # what it was baked from, to bake it again (fog_impostors.ludic) tw: int = 0, th: int = 0, - flower: bool = false + flower: bool = false, + src_path: string = "", # its bake file, where the game names one (bake_load.ludic): what a fog opening reads + src_key: string = "", + src_version: int = 0, + src_ok: bool = false # its atlases came from that bake, so a re-open reads it rather than painting } property Layer { model: Model, @@ -1230,14 +1234,18 @@ function carpet_bake__t(render3d_st: mut Render3dState, layers: []Layer, count: gpu_fb_free(render3d_st, fbo) gpu_rb_free(render3d_st, rb) gpu_buffer_free(render3d_st, buf) + carpet_finish(render3d_st, tex) + gpu_check(render3d_st, "carpet bake") + return tex +} +# the carpet tile made ready to wear: repeat-wrapped and mipmapped (a bake's too, bake_load.ludic) +function carpet_finish(render3d_st: mut Render3dState, tex: int) -> void { gpu_tex_bind(render3d_st, GPU_TEX2D, tex) gpu_tex_param(render3d_st, GPU_TEX2D, GL_TEXTURE_WRAP_S, GL_REPEAT) gpu_tex_param(render3d_st, GPU_TEX2D, GL_TEXTURE_WRAP_T, GL_REPEAT) gpu_tex_param(render3d_st, GPU_TEX2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) gpu_tex_paramf(render3d_st, GPU_TEX2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, render3d_st.tex_anisotropy) gpu_tex_mips(render3d_st, GPU_TEX2D) - gpu_check(render3d_st, "carpet bake") - return tex } # ---- another map at run time ------------------------------------------------------------ diff --git a/packages/ludic.render3d/sky.ludic b/packages/ludic.render3d/sky.ludic index 6bcb84e6..d30d2d93 100644 --- a/packages/ludic.render3d/sky.ludic +++ b/packages/ludic.render3d/sky.ludic @@ -6,6 +6,7 @@ # ============================================================================ const SKY_PREFILTER_LEVELS: int = 6 +const SKY_DEFAULT_HDRI: string = "kloofendal_48d_partly_cloudy_puresky_4k" # under /hdri/, when no path is given # The yaw to bake the light at the first time (radians, float bits). A game that turns the sky at # start sets it before r3d_init, so the image-based light is baked once at that yaw rather than at @@ -90,7 +91,13 @@ function sky_set_quality(render3d_st: mut Render3dState, w: int) -> void { if render3d_st.sky_irradiance != 0 { sky_precompute(render3d_st) } } +# the IBL set for the sky as it is turned now: from its bake where there is a current one (bake_load.ludic) function sky_precompute(render3d_st: mut Render3dState) -> void { + if sky_ibl_try_baked(render3d_st) { return } + sky_compute(render3d_st) +} +# the IBL set convolved from the HDRI on the GPU +function sky_compute(render3d_st: mut Render3dState) -> void { gpu_depth_test(render3d_st, false) sky_ibl_make(render3d_st) if render3d_st.sky_p_irr == 0 { render3d_st.sky_p_irr = r3d_program(render3d_st, "fullscreen.vert", "ibl_irradiance.frag", ""); render3d_st.sky_p_pre = r3d_program(render3d_st, "fullscreen.vert", "ibl_prefilter.frag", ""); render3d_st.sky_p_brdf = r3d_program(render3d_st, "fullscreen.vert", "ibl_brdf.frag", "") } From 3f2723582f9e5d38e9be4e787bb82f7d57f2d911 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 19:18:48 +0300 Subject: [PATCH 2/2] render3d: an impostor's bake is BC7 with its mips - both atlases uploaded compressed, as baked impostor_from_baked / impostor_refill read a bundle of two DX10 .dds (albedo, normal: BC7_UNORM, every level) through tex_load_dds_at and gpu_tex_compressed - no gpu_tex_mips - and check each is the impostor's size; sampled as a painted atlas is (clamped, no anisotropy). No BC on the GPU, or a bake that does not fit, and the caller paints RGBA8 and makes mips as before; the fog re-open reads the BC7 file again. The handles and every draw are unchanged. Compile-only: nothing run. Co-Authored-By: Claude Opus 5.5 --- changes/bake-images.md | 4 +- packages/ludic.render3d/bake_load.ludic | 49 ++++++++++++++++++++----- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/changes/bake-images.md b/changes/bake-images.md index 7a115526..aeaaf834 100644 --- a/changes/bake-images.md +++ b/changes/bake-images.md @@ -5,7 +5,7 @@ raw 8-bit pixels (1 to 4 channels) as a PNG (`lab_png_write`, `png_write.ludic`, own `LabPngState`) and turns float textures into honest previews (`png_convert.ludic`: R32F min..max as grey, RG16F as red and green x255, HDR RGBA16F as x/(1+x) then sRGB). `ludic.render3d` takes three bakes the game names (`bake_load.ludic`) and makes each as before when one is missing or stale: an impostor's -atlases (`impostor_from_baked`, and a fog opening past its cards reads the bake again instead of -painting), the sky's image-based light at the start yaw per prefilter width (`sky_baked_in`; any other +atlases as BC7 with their baked mips, through the compressed upload (`impostor_from_baked`, +`impostor_fill_bc7`; a fog opening past its cards reads the bake again instead of painting), the sky's image-based light at the start yaw per prefilter width (`sky_baked_in`; any other turn of the sky is convolved), and the grass carpet (`carpet_from_baked`, `carpet_bytes`). `impostor_from_bytes` returns null, keeping nothing, when the bytes are not the impostor's shape. diff --git a/packages/ludic.render3d/bake_load.ludic b/packages/ludic.render3d/bake_load.ludic index 7d3188e1..fdc74d60 100644 --- a/packages/ludic.render3d/bake_load.ludic +++ b/packages/ludic.render3d/bake_load.ludic @@ -23,31 +23,62 @@ function impostor_source(im: Impostor, path: string, key: string, version: int) im.src_version = version } -# an impostor of this shape from its bake, or null (missing, stale, or not this shape) for the caller to paint +# an impostor of this shape from its bake - both atlases BC7 with their mips, as tools/textures/bake_impostors.py +# makes them - or null (missing, stale, not this shape, or a GPU without BC) for the caller to paint @alloc_ok("a bake read once at a map load") function impostor_from_baked(render3d_st: mut Render3dState, model: Model, tiles: int, tw: int, th: int, path: string, key: string, version: int) -> Impostor { - let b = r3d_baked_read(path, key, version) - if b == null { return null } - let im = impostor_from_bytes(render3d_st, model, tiles, tw, th, b) - free(b) - if im == null { return null } + let im = new Impostor + im.tiles = tiles; im.radius = model.radius * 1.02; im.height = model.height + im.model = model; im.tw = tw; im.th = th; im.flower = render3d_st.sc_bake_flower impostor_source(im, path, key, version) + if not impostor_refill(render3d_st, im) { return null } im.src_ok = true return im } -# the atlases filled from the bake again (a fog opening past the cards); false, and none kept, when it cannot be -@alloc_ok("a bake read once when a fog opens") +# the atlases made from the bake (at a load, and again when a fog opens past the cards); false, and none +# kept, when it cannot be +@alloc_ok("a bake read once at a load or when a fog opens") function impostor_refill(render3d_st: mut Render3dState, im: Impostor) -> bool { + if not render3d_st.gvk_has_bc { return false } let b = r3d_baked_read(im.src_path, im.src_key, im.src_version) if b == null { return false } - let ok = impostor_fill(render3d_st, im, b) + let ok = impostor_fill_bc7(render3d_st, im, b) free(b) + return ok +} +# the two atlases from a bundle of two BC7 .dds (albedo, normal) whose size is the impostor's +function impostor_fill_bc7(render3d_st: mut Render3dState, im: Impostor, data: []byte) -> bool { + let was = render3d_st.gvk_tag + render3d_st.gvk_tag = VKM_IMPOSTOR + im.albedo = 0; im.normal = 0 + if bake_part_count(data) == 2 { + im.albedo = impostor_bc7_part(render3d_st, im, data, 0) + im.normal = impostor_bc7_part(render3d_st, im, data, 1) + } + let ok = im.albedo != 0 and im.normal != 0 if not ok { gpu_tex_free(render3d_st, im.albedo); gpu_tex_free(render3d_st, im.normal) im.albedo = 0; im.normal = 0 } + render3d_st.gvk_tag = was return ok } +# one atlas: its baked levels uploaded as they are (no gpu_tex_mips), sampled as a painted atlas is +function impostor_bc7_part(render3d_st: mut Render3dState, im: Impostor, data: []byte, part: int) -> int { + let at = bake_part_off(data, part) + if at + bake_part_len(data, part) > len(data) { return 0 } + let id = tex_load_dds_at(render3d_st, data, at, false) + if id == 0 { return 0 } + if render3d_st.tex_w != im.tiles * im.tw or render3d_st.tex_h != im.th { + gpu_tex_free(render3d_st, id) + return 0 + } + # tex_load_dds_at repeats and filters anisotropically; a painted atlas (tex_target) is clamped, with neither + gpu_tex_param(render3d_st, GPU_TEX2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + gpu_tex_param(render3d_st, GPU_TEX2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + gpu_tex_paramf(render3d_st, GPU_TEX2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, fixed(1.0)) # 1.0 is off, as a painted atlas has it + return id +} # ---- the sky's image-based light ---- # the directory the sky's bakes are in, under the key and version they were baked as: before r3d_init.