VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
envLighting.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// CPU-based environment lighting atlas generation.
5//
6#include "envLighting.h"
7
8#include <algorithm>
9#include <cmath>
10#include <cstring>
11#include <numbers>
12#include <unordered_map>
13
14#include <spdlog/spdlog.h>
15
16#include "core/math/random.h"
19
20namespace visutwin::canvas
21{
22 // ----------------------------------------------------------------
23 // Constants
24 // ----------------------------------------------------------------
25 static constexpr float ENV_PI = std::numbers::pi_v<float>;
26 static constexpr float ENV_TWO_PI = 2.0f * ENV_PI;
27
28 static int calcLevels(int size)
29 {
30 return 1 + static_cast<int>(std::floor(std::log2(std::max(size, 1))));
31 }
32
33 // ----------------------------------------------------------------
34 // Required samples table for GGX (pre-calculated)
35 // Matches upstream requiredSamplesGGX table
36 // ----------------------------------------------------------------
37 int EnvLighting::getRequiredSamplesGGX(int numSamples, int specularPower)
38 {
39 // Table generated by calculateRequiredSamplesGGX() in the upstream engine
40 static const std::unordered_map<int, std::unordered_map<int, int>> table = {
41 {16, {{2, 26}, {8, 20}, {32, 17}, {128, 16}, {512, 16}}},
42 {32, {{2, 53}, {8, 40}, {32, 34}, {128, 32}, {512, 32}}},
43 {128, {{2, 214}, {8, 163}, {32, 139}, {128, 130}, {512, 128}}},
44 {1024, {{2, 1722}, {8, 1310}, {32, 1114}, {128, 1041}, {512, 1025}}}
45 };
46
47 auto it = table.find(numSamples);
48 if (it != table.end()) {
49 auto it2 = it->second.find(specularPower);
50 if (it2 != it->second.end()) {
51 return it2->second;
52 }
53 }
54 return numSamples;
55 }
56
57 // ----------------------------------------------------------------
58 // RGBP encoding
59 // ----------------------------------------------------------------
60 std::array<uint8_t, 4> EnvLighting::encodeRGBP(float r, float g, float b)
61 {
62 // Decode in shader: color = rgb * (-a * 7.0 + 8.0); color = color * color;
63 // So encode: sqrt(color), then find 'a' such that rgb / (-a*7+8) fits in [0,1]
64 const float sr = std::sqrt(std::max(r, 0.0f));
65 const float sg = std::sqrt(std::max(g, 0.0f));
66 const float sb = std::sqrt(std::max(b, 0.0f));
67
68 const float maxVal = std::max({sr, sg, sb, 1.0f / 255.0f});
69 const float a = std::clamp((8.0f - maxVal) / 7.0f, 0.0f, 1.0f);
70 const float scale = -a * 7.0f + 8.0f;
71
72 return {
73 static_cast<uint8_t>(std::clamp(sr / scale * 255.0f + 0.5f, 0.0f, 255.0f)),
74 static_cast<uint8_t>(std::clamp(sg / scale * 255.0f + 0.5f, 0.0f, 255.0f)),
75 static_cast<uint8_t>(std::clamp(sb / scale * 255.0f + 0.5f, 0.0f, 255.0f)),
76 static_cast<uint8_t>(std::clamp(a * 255.0f + 0.5f, 0.0f, 255.0f))
77 };
78 }
79
80 // ----------------------------------------------------------------
81 // Direction <-> equirectangular UV
82 // Matches Metal shader toSphericalUv()
83 // ----------------------------------------------------------------
84 void EnvLighting::dirToEquirectUv(float x, float y, float z, float& u, float& v)
85 {
86 const float phi = std::atan2(x, z); // azimuth
87 const float theta = std::asin(std::clamp(y, -1.0f, 1.0f)); // elevation
88 u = phi / ENV_TWO_PI + 0.5f;
89 v = 1.0f - (theta / ENV_PI + 0.5f);
90 }
91
92 void EnvLighting::equirectUvToDir(float u, float v, float& x, float& y, float& z)
93 {
94 const float phi = (u - 0.5f) * ENV_TWO_PI; // azimuth
95 const float theta = (0.5f - v) * ENV_PI; // elevation
96 const float cosTheta = std::cos(theta);
97 x = std::sin(phi) * cosTheta;
98 y = std::sin(theta);
99 z = std::cos(phi) * cosTheta;
100 }
101
102 // ----------------------------------------------------------------
103 // Direction -> cubemap face + UV
104 // Standard cubemap face mapping (OpenGL convention)
105 // ----------------------------------------------------------------
106 void EnvLighting::dirToFaceUv(float x, float y, float z, int& face, float& u, float& v)
107 {
108 const float ax = std::abs(x);
109 const float ay = std::abs(y);
110 const float az = std::abs(z);
111
112 float ma, sc, tc;
113 if (ax >= ay && ax >= az) {
114 ma = ax;
115 if (x > 0) { face = 0; sc = -z; tc = -y; } // +X
116 else { face = 1; sc = z; tc = -y; } // -X
117 } else if (ay >= ax && ay >= az) {
118 ma = ay;
119 if (y > 0) { face = 2; sc = x; tc = z; } // +Y
120 else { face = 3; sc = x; tc = -z; } // -Y
121 } else {
122 ma = az;
123 if (z > 0) { face = 4; sc = x; tc = -y; } // +Z
124 else { face = 5; sc = -x; tc = -y; } // -Z
125 }
126
127 u = (sc / ma + 1.0f) * 0.5f;
128 v = (tc / ma + 1.0f) * 0.5f;
129 }
130
131 // Face index + UV -> 3D direction (inverse of dirToFaceUv)
132 static void faceUvToDir(int face, float u, float v, float& x, float& y, float& z)
133 {
134 // Convert UV [0,1] to [-1,1]
135 const float sc = u * 2.0f - 1.0f;
136 const float tc = v * 2.0f - 1.0f;
137
138 switch (face) {
139 case 0: x = 1.0f; y = -tc; z = -sc; break; // +X
140 case 1: x = -1.0f; y = -tc; z = sc; break; // -X
141 case 2: x = sc; y = 1.0f; z = tc; break; // +Y
142 case 3: x = sc; y = -1.0f; z = -tc; break; // -Y
143 case 4: x = sc; y = -tc; z = 1.0f; break; // +Z
144 case 5: x = -sc; y = -tc; z = -1.0f; break; // -Z
145 default: x = y = z = 0.0f; break;
146 }
147
148 // Normalize
149 const float len = std::sqrt(x * x + y * y + z * z);
150 if (len > 0.0f) {
151 x /= len;
152 y /= len;
153 z /= len;
154 }
155 }
156
157 // ----------------------------------------------------------------
158 // Bilinear sample from a single cubemap face
159 // ----------------------------------------------------------------
160 EnvLighting::Color3 EnvLighting::sampleFace(const std::vector<float>& faceData, int faceSize, float u, float v)
161 {
162 // Clamp UV to valid range
163 u = std::clamp(u, 0.0f, 1.0f);
164 v = std::clamp(v, 0.0f, 1.0f);
165
166 const float fx = u * static_cast<float>(faceSize - 1);
167 const float fy = v * static_cast<float>(faceSize - 1);
168
169 const int x0 = std::clamp(static_cast<int>(std::floor(fx)), 0, faceSize - 1);
170 const int y0 = std::clamp(static_cast<int>(std::floor(fy)), 0, faceSize - 1);
171 const int x1 = std::min(x0 + 1, faceSize - 1);
172 const int y1 = std::min(y0 + 1, faceSize - 1);
173
174 const float sx = fx - static_cast<float>(x0);
175 const float sy = fy - static_cast<float>(y0);
176
177 auto pixel = [&](int px, int py) -> const float* {
178 return &faceData[(py * faceSize + px) * 4];
179 };
180
181 const float* p00 = pixel(x0, y0);
182 const float* p10 = pixel(x1, y0);
183 const float* p01 = pixel(x0, y1);
184 const float* p11 = pixel(x1, y1);
185
186 Color3 result;
187 for (int c = 0; c < 3; ++c) {
188 const float top = p00[c] * (1.0f - sx) + p10[c] * sx;
189 const float bot = p01[c] * (1.0f - sx) + p11[c] * sx;
190 (&result.r)[c] = top * (1.0f - sy) + bot * sy;
191 }
192 return result;
193 }
194
195 // ----------------------------------------------------------------
196 // Trilinear cubemap sample
197 // ----------------------------------------------------------------
198 EnvLighting::Color3 EnvLighting::sampleCubemap(const HdrCubemap& cubemap, float dirX, float dirY, float dirZ, float mipLevel)
199 {
200 mipLevel = std::clamp(mipLevel, 0.0f, static_cast<float>(cubemap.numLevels - 1));
201
202 int face;
203 float u, v;
204 dirToFaceUv(dirX, dirY, dirZ, face, u, v);
205
206 const int mip0 = std::clamp(static_cast<int>(std::floor(mipLevel)), 0, cubemap.numLevels - 1);
207 const int mip1 = std::min(mip0 + 1, cubemap.numLevels - 1);
208 const float frac = mipLevel - static_cast<float>(mip0);
209
210 const int size0 = std::max(1, cubemap.size >> mip0);
211 const int size1 = std::max(1, cubemap.size >> mip1);
212
213 Color3 c0 = sampleFace(cubemap.data[mip0][face], size0, u, v);
214
215 if (mip0 == mip1 || frac < 0.001f) {
216 return c0;
217 }
218
219 Color3 c1 = sampleFace(cubemap.data[mip1][face], size1, u, v);
220 return {
221 c0.r * (1.0f - frac) + c1.r * frac,
222 c0.g * (1.0f - frac) + c1.g * frac,
223 c0.b * (1.0f - frac) + c1.b * frac
224 };
225 }
226
227 // ----------------------------------------------------------------
228 // Equirect -> HDR cubemap
229 // ----------------------------------------------------------------
230 EnvLighting::HdrCubemap EnvLighting::equirectToCubemap(Texture* source, int size)
231 {
232 const auto* srcData = static_cast<const float*>(source->getLevel(0));
233 const int srcW = static_cast<int>(source->width());
234 const int srcH = static_cast<int>(source->height());
235 return equirectToCubemap(srcData, srcW, srcH, size);
236 }
237
238 EnvLighting::HdrCubemap EnvLighting::equirectToCubemap(const float* srcData, int srcW, int srcH, int size)
239 {
240 HdrCubemap cubemap;
241 cubemap.size = size;
242 cubemap.numLevels = 1;
243 cubemap.data.resize(1);
244 cubemap.data[0].resize(6);
245
246 if (!srcData || srcW == 0 || srcH == 0) {
247 spdlog::error("EnvLighting: source texture has no pixel data ({}x{})", srcW, srcH);
248 return cubemap;
249 }
250
251 // Bilinear sample from equirect source
252 auto sampleEquirect = [&](float u, float v) -> Color3 {
253 u = u - std::floor(u); // wrap horizontally
254 v = std::clamp(v, 0.0f, 1.0f);
255
256 const float fx = u * static_cast<float>(srcW - 1);
257 const float fy = v * static_cast<float>(srcH - 1);
258
259 const int x0 = std::clamp(static_cast<int>(std::floor(fx)), 0, srcW - 1);
260 const int y0 = std::clamp(static_cast<int>(std::floor(fy)), 0, srcH - 1);
261 const int x1 = (x0 + 1) % srcW; // wrap horizontally
262 const int y1 = std::min(y0 + 1, srcH - 1);
263
264 const float sx = fx - static_cast<float>(x0);
265 const float sy = fy - static_cast<float>(y0);
266
267 auto pixel = [&](int px, int py) -> const float* {
268 return &srcData[(py * srcW + px) * 4];
269 };
270
271 const float* p00 = pixel(x0, y0);
272 const float* p10 = pixel(x1, y0);
273 const float* p01 = pixel(x0, y1);
274 const float* p11 = pixel(x1, y1);
275
276 Color3 result;
277 for (int c = 0; c < 3; ++c) {
278 const float top = p00[c] * (1.0f - sx) + p10[c] * sx;
279 const float bot = p01[c] * (1.0f - sx) + p11[c] * sx;
280 (&result.r)[c] = top * (1.0f - sy) + bot * sy;
281 }
282 return result;
283 };
284
285 // For each face, compute direction per pixel and sample equirect
286 for (int face = 0; face < 6; ++face) {
287 auto& faceData = cubemap.data[0][face];
288 faceData.resize(size * size * 4);
289
290 for (int py = 0; py < size; ++py) {
291 for (int px = 0; px < size; ++px) {
292 const float u = (static_cast<float>(px) + 0.5f) / static_cast<float>(size);
293 const float v = (static_cast<float>(py) + 0.5f) / static_cast<float>(size);
294
295 float dx, dy, dz;
296 faceUvToDir(face, u, v, dx, dy, dz);
297
298 float eu, ev;
299 dirToEquirectUv(dx, dy, dz, eu, ev);
300
301 Color3 color = sampleEquirect(eu, ev);
302 const int idx = (py * size + px) * 4;
303 faceData[idx + 0] = color.r;
304 faceData[idx + 1] = color.g;
305 faceData[idx + 2] = color.b;
306 faceData[idx + 3] = 1.0f;
307 }
308 }
309 }
310
311 return cubemap;
312 }
313
314 // ----------------------------------------------------------------
315 // Generate box-filter mipmaps
316 // ----------------------------------------------------------------
317 void EnvLighting::generateMipmaps(HdrCubemap& cubemap)
318 {
319 const int maxLevels = calcLevels(cubemap.size);
320 cubemap.numLevels = maxLevels;
321 cubemap.data.resize(maxLevels);
322
323 for (int mip = 1; mip < maxLevels; ++mip) {
324 const int prevSize = std::max(1, cubemap.size >> (mip - 1));
325 const int currSize = std::max(1, cubemap.size >> mip);
326
327 cubemap.data[mip].resize(6);
328
329 for (int face = 0; face < 6; ++face) {
330 auto& prevFace = cubemap.data[mip - 1][face];
331 auto& currFace = cubemap.data[mip][face];
332 currFace.resize(currSize * currSize * 4);
333
334 for (int py = 0; py < currSize; ++py) {
335 for (int px = 0; px < currSize; ++px) {
336 const int sx = px * 2;
337 const int sy = py * 2;
338 const int sx1 = std::min(sx + 1, prevSize - 1);
339 const int sy1 = std::min(sy + 1, prevSize - 1);
340
341 const int dstIdx = (py * currSize + px) * 4;
342
343 for (int c = 0; c < 4; ++c) {
344 const float v00 = prevFace[(sy * prevSize + sx) * 4 + c];
345 const float v10 = prevFace[(sy * prevSize + sx1) * 4 + c];
346 const float v01 = prevFace[(sy1 * prevSize + sx) * 4 + c];
347 const float v11 = prevFace[(sy1 * prevSize + sx1) * 4 + c];
348 currFace[dstIdx + c] = (v00 + v10 + v01 + v11) * 0.25f;
349 }
350 }
351 }
352 }
353 }
354 }
355
356 // ----------------------------------------------------------------
357 // Hemisphere sampling
358 // Port from upstream reproject-texture.js
359 // ----------------------------------------------------------------
360 void EnvLighting::hemisphereSampleGGX(float& hx, float& hy, float& hz, float xi1, float xi2, float a)
361 {
362 const float phi = xi2 * ENV_TWO_PI;
363 const float cosTheta = std::sqrt((1.0f - xi1) / (1.0f + (a * a - 1.0f) * xi1));
364 const float sinTheta = std::sqrt(std::max(0.0f, 1.0f - cosTheta * cosTheta));
365 hx = std::cos(phi) * sinTheta;
366 hy = std::sin(phi) * sinTheta;
367 hz = cosTheta;
368 // Normalize
369 const float len = std::sqrt(hx * hx + hy * hy + hz * hz);
370 if (len > 0.0f) {
371 hx /= len;
372 hy /= len;
373 hz /= len;
374 }
375 }
376
377 void EnvLighting::hemisphereSampleLambert(float& hx, float& hy, float& hz, float xi1, float xi2)
378 {
379 const float phi = xi2 * ENV_TWO_PI;
380 const float cosTheta = std::sqrt(1.0f - xi1);
381 const float sinTheta = std::sqrt(xi1);
382 hx = std::cos(phi) * sinTheta;
383 hy = std::sin(phi) * sinTheta;
384 hz = cosTheta;
385 // Normalize
386 const float len = std::sqrt(hx * hx + hy * hy + hz * hz);
387 if (len > 0.0f) {
388 hx /= len;
389 hy /= len;
390 hz /= len;
391 }
392 }
393
394 float EnvLighting::D_GGX(float NoH, float linearRoughness)
395 {
396 const float a = NoH * linearRoughness;
397 const float k = linearRoughness / (1.0f - NoH * NoH + a * a);
398 return k * k * (1.0f / ENV_PI);
399 }
400
401 // ----------------------------------------------------------------
402 // Write equirectangular region (direct resample, no convolution)
403 // Used for mipmaps section of atlas (lower mip levels from cubemap)
404 // ----------------------------------------------------------------
405 void EnvLighting::writeEquirectRegion(uint8_t* atlas, int atlasSize,
406 int rx, int ry, int rw, int rh,
407 const HdrCubemap& cubemap, float mipLevel)
408 {
409 const float seamPixels = 1.0f;
410 const float invRw = 1.0f / static_cast<float>(rw);
411 const float invRh = 1.0f / static_cast<float>(rh);
412
413 for (int py = 0; py < rh; ++py) {
414 for (int px = 0; px < rw; ++px) {
415 // UV with seam handling: expand UV range slightly for border pixels
416 float u = (static_cast<float>(px) + 0.5f) * invRw;
417 float v = (static_cast<float>(py) + 0.5f) * invRh;
418
419 // Apply seam correction (inverse of shader's mapUv seam inset)
420 const float innerW = static_cast<float>(rw) - seamPixels * 2.0f;
421 const float innerH = static_cast<float>(rh) - seamPixels * 2.0f;
422 if (innerW > 0.0f && innerH > 0.0f) {
423 u = (u * static_cast<float>(rw) - seamPixels) / innerW;
424 v = (v * static_cast<float>(rh) - seamPixels) / innerH;
425 }
426
427 // Convert UV to direction
428 float dx, dy, dz;
429 equirectUvToDir(u, v, dx, dy, dz);
430
431 // Sample cubemap
432 Color3 color = sampleCubemap(cubemap, dx, dy, dz, mipLevel);
433
434 // Encode and write
435 auto encoded = encodeRGBP(color.r, color.g, color.b);
436 const int atlasIdx = ((ry + py) * atlasSize + (rx + px)) * 4;
437 atlas[atlasIdx + 0] = encoded[0];
438 atlas[atlasIdx + 1] = encoded[1];
439 atlas[atlasIdx + 2] = encoded[2];
440 atlas[atlasIdx + 3] = encoded[3];
441 }
442 }
443 }
444
445 // ----------------------------------------------------------------
446 // Write equirectangular region directly from source equirect texture.
447 // reprojectTexture with numSamples=1 samples the
448 // source equirect directly, preserving full source resolution.
449 // ----------------------------------------------------------------
450 void EnvLighting::writeEquirectFromSource(uint8_t* atlas, int atlasSize,
451 int rx, int ry, int rw, int rh,
452 const float* srcData, int srcW, int srcH)
453 {
454 const float seamPixels = 1.0f;
455 const float invRw = 1.0f / static_cast<float>(rw);
456 const float invRh = 1.0f / static_cast<float>(rh);
457
458 for (int py = 0; py < rh; ++py) {
459 for (int px = 0; px < rw; ++px) {
460 float u = (static_cast<float>(px) + 0.5f) * invRw;
461 float v = (static_cast<float>(py) + 0.5f) * invRh;
462
463 // Apply seam correction
464 const float innerW = static_cast<float>(rw) - seamPixels * 2.0f;
465 const float innerH = static_cast<float>(rh) - seamPixels * 2.0f;
466 if (innerW > 0.0f && innerH > 0.0f) {
467 u = (u * static_cast<float>(rw) - seamPixels) / innerW;
468 v = (v * static_cast<float>(rh) - seamPixels) / innerH;
469 }
470
471 // Convert atlas UV to direction, then back to source equirect UV.
472 // This round-trip (UV→dir→UV) is needed because the atlas rect
473 // is a sub-region, so atlas UV != source equirect UV.
474 float dx, dy, dz;
475 equirectUvToDir(u, v, dx, dy, dz);
476
477 float srcU, srcV;
478 dirToEquirectUv(dx, dy, dz, srcU, srcV);
479
480 // Bilinear sample from source equirect (RGBA32F)
481 srcU = srcU - std::floor(srcU); // wrap horizontally
482 srcV = std::clamp(srcV, 0.0f, 1.0f);
483
484 const float fx = srcU * static_cast<float>(srcW - 1);
485 const float fy = srcV * static_cast<float>(srcH - 1);
486
487 const int x0 = std::clamp(static_cast<int>(std::floor(fx)), 0, srcW - 1);
488 const int y0 = std::clamp(static_cast<int>(std::floor(fy)), 0, srcH - 1);
489 const int x1 = (x0 + 1) % srcW;
490 const int y1 = std::min(y0 + 1, srcH - 1);
491
492 const float sx = fx - static_cast<float>(x0);
493 const float sy = fy - static_cast<float>(y0);
494
495 auto pixel = [&](int ppx, int ppy) -> const float* {
496 return &srcData[(ppy * srcW + ppx) * 4];
497 };
498
499 const float* p00 = pixel(x0, y0);
500 const float* p10 = pixel(x1, y0);
501 const float* p01 = pixel(x0, y1);
502 const float* p11 = pixel(x1, y1);
503
504 float r = 0.0f, g = 0.0f, b = 0.0f;
505 for (int c = 0; c < 3; ++c) {
506 const float top = p00[c] * (1.0f - sx) + p10[c] * sx;
507 const float bot = p01[c] * (1.0f - sx) + p11[c] * sx;
508 const float val = top * (1.0f - sy) + bot * sy;
509 if (c == 0) r = val;
510 else if (c == 1) g = val;
511 else b = val;
512 }
513
514 auto encoded = encodeRGBP(r, g, b);
515 const int atlasIdx = ((ry + py) * atlasSize + (rx + px)) * 4;
516 atlas[atlasIdx + 0] = encoded[0];
517 atlas[atlasIdx + 1] = encoded[1];
518 atlas[atlasIdx + 2] = encoded[2];
519 atlas[atlasIdx + 3] = encoded[3];
520 }
521 }
522 }
523
524 // ----------------------------------------------------------------
525 // Write GGX-prefiltered equirectangular region
526 // Port of prefilterSamples from upstream reproject-texture.js
527 // ----------------------------------------------------------------
528 void EnvLighting::writeGGXRegion(uint8_t* atlas, int atlasSize,
529 int rx, int ry, int rw, int rh,
530 const HdrCubemap& cubemap,
531 int specularPower, int numSamples)
532 {
533 const float roughness = 1.0f - std::log2(static_cast<float>(std::max(specularPower, 1))) / 11.0f;
534 const float a = roughness * roughness;
535
536 // Source total pixels for mip level calculation
537 const float sourceTotalPixels = static_cast<float>(cubemap.size * cubemap.size * 6);
538 const float pixelsPerSample = sourceTotalPixels / static_cast<float>(numSamples);
539
540 // Pre-compute GGX samples (like upstream generateGGXSamples)
541 const int requiredSamples = getRequiredSamplesGGX(numSamples, specularPower);
542
543 struct Sample { float lx, ly, lz, mipLevel; };
544 std::vector<Sample> samples;
545 samples.reserve(numSamples);
546
547 for (int i = 0; i < requiredSamples && static_cast<int>(samples.size()) < numSamples; ++i) {
548 float hx, hy, hz;
549 hemisphereSampleGGX(hx, hy, hz,
550 static_cast<float>(i) / static_cast<float>(requiredSamples),
552
553 const float NoH = hz; // N = (0, 0, 1)
554 // L = 2 * (N.H) * H - N
555 float lx = 2.0f * NoH * hx;
556 float ly = 2.0f * NoH * hy;
557 float lz = 2.0f * NoH * hz - 1.0f;
558
559 if (lz > 0.0f) {
560 const float pdf = D_GGX(std::min(1.0f, NoH), a) / 4.0f + 0.001f;
561 const float mip = 0.5f * std::log2(pixelsPerSample / pdf);
562 samples.push_back({lx, ly, lz, mip});
563 }
564 }
565
566 // Pad with zeros if needed
567 while (static_cast<int>(samples.size()) < numSamples) {
568 samples.push_back({0, 0, 0, 0});
569 }
570
571 const float seamPixels = 1.0f;
572 const float invRw = 1.0f / static_cast<float>(rw);
573 const float invRh = 1.0f / static_cast<float>(rh);
574
575 for (int py = 0; py < rh; ++py) {
576 for (int px = 0; px < rw; ++px) {
577 float u = (static_cast<float>(px) + 0.5f) * invRw;
578 float v = (static_cast<float>(py) + 0.5f) * invRh;
579
580 // Seam correction
581 const float innerW = static_cast<float>(rw) - seamPixels * 2.0f;
582 const float innerH = static_cast<float>(rh) - seamPixels * 2.0f;
583 if (innerW > 0.0f && innerH > 0.0f) {
584 u = (u * static_cast<float>(rw) - seamPixels) / innerW;
585 v = (v * static_cast<float>(rh) - seamPixels) / innerH;
586 }
587
588 // Direction for this pixel (N = direction at center of this texel)
589 float nx, ny, nz;
590 equirectUvToDir(u, v, nx, ny, nz);
591
592 // Build TBN frame for this direction
593 // Choose an up vector that's not parallel to N
594 float upX = 0.0f, upY = 1.0f, upZ = 0.0f;
595 if (std::abs(ny) > 0.999f) {
596 upX = 1.0f; upY = 0.0f; upZ = 0.0f;
597 }
598 // T = normalize(cross(up, N))
599 float tx = upY * nz - upZ * ny;
600 float ty = upZ * nx - upX * nz;
601 float tz = upX * ny - upY * nx;
602 float tlen = std::sqrt(tx * tx + ty * ty + tz * tz);
603 if (tlen > 0.0f) { tx /= tlen; ty /= tlen; tz /= tlen; }
604 // B = cross(N, T)
605 float bx = ny * tz - nz * ty;
606 float by = nz * tx - nx * tz;
607 float bz = nx * ty - ny * tx;
608
609 // Accumulate importance-sampled GGX
610 float sumR = 0.0f, sumG = 0.0f, sumB = 0.0f;
611 float totalWeight = 0.0f;
612
613 for (const auto& s : samples) {
614 if (s.lz <= 0.0f) continue;
615
616 // Transform sample direction from tangent space to world space
617 const float wx = tx * s.lx + bx * s.ly + nx * s.lz;
618 const float wy = ty * s.lx + by * s.ly + ny * s.lz;
619 const float wz = tz * s.lx + bz * s.ly + nz * s.lz;
620
621 Color3 color = sampleCubemap(cubemap, wx, wy, wz, s.mipLevel);
622 sumR += color.r;
623 sumG += color.g;
624 sumB += color.b;
625 totalWeight += 1.0f;
626 }
627
628 if (totalWeight > 0.0f) {
629 sumR /= totalWeight;
630 sumG /= totalWeight;
631 sumB /= totalWeight;
632 }
633
634 auto encoded = encodeRGBP(sumR, sumG, sumB);
635 const int atlasIdx = ((ry + py) * atlasSize + (rx + px)) * 4;
636 atlas[atlasIdx + 0] = encoded[0];
637 atlas[atlasIdx + 1] = encoded[1];
638 atlas[atlasIdx + 2] = encoded[2];
639 atlas[atlasIdx + 3] = encoded[3];
640 }
641 }
642 }
643
644 // ----------------------------------------------------------------
645 // Write Lambert-convolved equirectangular region
646 // Port of prefilterSamplesUnweighted from upstream
647 // ----------------------------------------------------------------
648 void EnvLighting::writeLambertRegion(uint8_t* atlas, int atlasSize,
649 int rx, int ry, int rw, int rh,
650 const HdrCubemap& cubemap, int numSamples)
651 {
652 // Source total pixels for mip level calculation
653 const float sourceTotalPixels = static_cast<float>(cubemap.size * cubemap.size * 6);
654 const float pixelsPerSample = sourceTotalPixels / static_cast<float>(numSamples);
655
656 // Pre-compute Lambert samples
657 struct Sample { float hx, hy, hz, mipLevel; };
658 std::vector<Sample> samples;
659 samples.reserve(numSamples);
660
661 for (int i = 0; i < numSamples; ++i) {
662 float hx, hy, hz;
663 hemisphereSampleLambert(hx, hy, hz,
664 static_cast<float>(i) / static_cast<float>(numSamples),
666
667 const float pdf = hz / ENV_PI; // Lambert PDF = cos(theta) / pi
668 const float mip = 0.5f * std::log2(pixelsPerSample / std::max(pdf, 0.001f));
669 samples.push_back({hx, hy, hz, mip});
670 }
671
672 const float seamPixels = 1.0f;
673 const float invRw = 1.0f / static_cast<float>(rw);
674 const float invRh = 1.0f / static_cast<float>(rh);
675
676 for (int py = 0; py < rh; ++py) {
677 for (int px = 0; px < rw; ++px) {
678 float u = (static_cast<float>(px) + 0.5f) * invRw;
679 float v = (static_cast<float>(py) + 0.5f) * invRh;
680
681 // Seam correction
682 const float innerW = static_cast<float>(rw) - seamPixels * 2.0f;
683 const float innerH = static_cast<float>(rh) - seamPixels * 2.0f;
684 if (innerW > 0.0f && innerH > 0.0f) {
685 u = (u * static_cast<float>(rw) - seamPixels) / innerW;
686 v = (v * static_cast<float>(rh) - seamPixels) / innerH;
687 }
688
689 float nx, ny, nz;
690 equirectUvToDir(u, v, nx, ny, nz);
691
692 // Build TBN frame
693 float upX = 0.0f, upY = 1.0f, upZ = 0.0f;
694 if (std::abs(ny) > 0.999f) {
695 upX = 1.0f; upY = 0.0f; upZ = 0.0f;
696 }
697 float tx = upY * nz - upZ * ny;
698 float ty = upZ * nx - upX * nz;
699 float tz = upX * ny - upY * nx;
700 float tlen = std::sqrt(tx * tx + ty * ty + tz * tz);
701 if (tlen > 0.0f) { tx /= tlen; ty /= tlen; tz /= tlen; }
702 float bx = ny * tz - nz * ty;
703 float by = nz * tx - nx * tz;
704 float bz = nx * ty - ny * tx;
705
706 float sumR = 0.0f, sumG = 0.0f, sumB = 0.0f;
707 float totalWeight = 0.0f;
708
709 for (const auto& s : samples) {
710 // Transform from tangent to world
711 const float wx = tx * s.hx + bx * s.hy + nx * s.hz;
712 const float wy = ty * s.hx + by * s.hy + ny * s.hz;
713 const float wz = tz * s.hx + bz * s.hy + nz * s.hz;
714
715 Color3 color = sampleCubemap(cubemap, wx, wy, wz, s.mipLevel);
716 sumR += color.r;
717 sumG += color.g;
718 sumB += color.b;
719 totalWeight += 1.0f;
720 }
721
722 if (totalWeight > 0.0f) {
723 sumR /= totalWeight;
724 sumG /= totalWeight;
725 sumB /= totalWeight;
726 }
727
728 auto encoded = encodeRGBP(sumR, sumG, sumB);
729 const int atlasIdx = ((ry + py) * atlasSize + (rx + px)) * 4;
730 atlas[atlasIdx + 0] = encoded[0];
731 atlas[atlasIdx + 1] = encoded[1];
732 atlas[atlasIdx + 2] = encoded[2];
733 atlas[atlasIdx + 3] = encoded[3];
734 }
735 }
736 }
737
738 // ----------------------------------------------------------------
739 // generateAtlas - main entry point
740 // Port of EnvLighting.generateAtlas() from upstream
741 // ----------------------------------------------------------------
743 int size, int numReflectionSamples, int numAmbientSamples)
744 {
745 if (!device || !source) {
746 spdlog::error("EnvLighting::generateAtlas: null device or source");
747 return nullptr;
748 }
749
750 spdlog::info("EnvLighting: generating {}x{} RGBP atlas from {}x{} equirect source",
751 size, size, source->width(), source->height());
752
753 // Get source equirect pixel data for direct sampling
754 const auto* srcData = static_cast<const float*>(source->getLevel(0));
755 const int srcW = static_cast<int>(source->width());
756 const int srcH = static_cast<int>(source->height());
757
758 // Step 1: Convert equirect source to HDR cubemap for GGX/Lambert convolution.
759 // the cubemap is only used for hemisphere-sampled convolution
760 // (GGX reflections + Lambert ambient). The mipmap section samples the source
761 // equirect directly to preserve full resolution.
762 const int cubemapSize = 256;
763 auto cubemap = equirectToCubemap(source, cubemapSize);
764 spdlog::info("EnvLighting: cubemap created ({}x{}, {} faces)", cubemapSize, cubemapSize, 6);
765
766 // Step 2: Generate cubemap mipmaps
767 generateMipmaps(cubemap);
768 spdlog::info("EnvLighting: {} mip levels generated", cubemap.numLevels);
769
770 // Step 3: Allocate output atlas buffer (RGBA8)
771 std::vector<uint8_t> atlasData(size * size * 4, 0);
772
773 // Step 4: Mipmaps section
774 // reprojectTexture with numSamples=1 samples the source equirect
775 // directly for each atlas pixel. This preserves full source resolution (e.g. 4K HDR)
776 // rather than going through a cubemap intermediate.
777 // Rect starts at (0,0,512,256) then shrinks diagonally.
778 // levels = calcLevels(256) - calcLevels(4) = 9 - 3 = 6 ... total 7 levels (i=0..6)
779 {
780 const int levels = calcLevels(256) - calcLevels(4);
781 int rectX = 0, rectY = 0, rectW = size, rectH = size / 2;
782
783 for (int i = 0; i <= levels; ++i) {
784 if (rectW < 1 || rectH < 1) break;
785
786 spdlog::debug("EnvLighting: mipmap level {} -> rect({}, {}, {}, {})", i, rectX, rectY, rectW, rectH);
787
788 if (srcData && srcW > 0 && srcH > 0) {
789 // Sample directly from source equirect for maximum quality
790 writeEquirectFromSource(atlasData.data(), size, rectX, rectY, rectW, rectH,
791 srcData, srcW, srcH);
792 } else {
793 // Fallback: sample from cubemap if source data is unavailable
794 writeEquirectRegion(atlasData.data(), size, rectX, rectY, rectW, rectH,
795 cubemap, static_cast<float>(i));
796 }
797
798 rectX += rectH; // x += height (since width = 2*height, this shifts diagonally)
799 rectY += rectH;
800 rectW = std::max(1, rectW / 2);
801 rectH = std::max(1, rectH / 2);
802 }
803 }
804
805 // Step 5: Reflections section (GGX prefiltered)
806 // Matches upstream: rect starts at (0, 256, 256, 128) then shrinks vertically
807 {
808 int rectX = 0, rectY = size / 2, rectW = size / 2, rectH = size / 4;
809
810 for (int i = 1; i <= 6; ++i) {
811 if (rectW < 1 || rectH < 1) break;
812
813 const int specularPower = std::max(1, 2048 >> (i * 2));
814 spdlog::info("EnvLighting: GGX blur level {} (specularPower={}) -> rect({}, {}, {}, {})",
815 i, specularPower, rectX, rectY, rectW, rectH);
816
817 writeGGXRegion(atlasData.data(), size, rectX, rectY, rectW, rectH,
818 cubemap, specularPower, numReflectionSamples);
819
820 rectY += rectH;
821 rectW = std::max(1, rectW / 2);
822 rectH = std::max(1, rectH / 2);
823 }
824 }
825
826 // Step 6: Ambient section (Lambert irradiance)
827 // Matches upstream: rect(128, 384, 64, 32) for 512-sized atlas
828 {
829 const int rectX = size / 4;
830 const int rectY = size / 2 + size / 4;
831 const int rectW = size / 8;
832 const int rectH = size / 16;
833
834 spdlog::info("EnvLighting: Lambert ambient -> rect({}, {}, {}, {})", rectX, rectY, rectW, rectH);
835 writeLambertRegion(atlasData.data(), size, rectX, rectY, rectW, rectH,
836 cubemap, numAmbientSamples);
837 }
838
839 // Step 7: Create GPU texture
840 TextureOptions options;
841 options.name = "envAtlas";
842 options.width = size;
843 options.height = size;
845 options.mipmaps = false;
848
849 auto* texture = new Texture(device, options);
850 texture->setEncoding(TextureEncoding::RGBP);
851 texture->setLevelData(0, atlasData.data(), atlasData.size());
852 texture->upload();
853
854 spdlog::info("EnvLighting: atlas generated successfully ({}x{}, RGBP)", size, size);
855
856 return texture;
857 }
858
859 // ----------------------------------------------------------------
860 // generateAtlasRaw - CPU-only entry point (no GraphicsDevice needed)
861 // ----------------------------------------------------------------
862 std::vector<uint8_t> EnvLighting::generateAtlasRaw(const float* srcData, int srcW, int srcH,
863 int size, int numReflectionSamples, int numAmbientSamples)
864 {
865 if (!srcData || srcW <= 0 || srcH <= 0) {
866 spdlog::error("EnvLighting::generateAtlasRaw: invalid source data ({}x{})", srcW, srcH);
867 return {};
868 }
869
870 spdlog::info("EnvLighting: generating {}x{} RGBP atlas from {}x{} equirect source (CPU-only)",
871 size, size, srcW, srcH);
872
873 // Step 1: Convert equirect source to HDR cubemap
874 const int cubemapSize = 256;
875 auto cubemap = equirectToCubemap(srcData, srcW, srcH, cubemapSize);
876 spdlog::info("EnvLighting: cubemap created ({}x{}, {} faces)", cubemapSize, cubemapSize, 6);
877
878 // Step 2: Generate cubemap mipmaps
879 generateMipmaps(cubemap);
880 spdlog::info("EnvLighting: {} mip levels generated", cubemap.numLevels);
881
882 // Step 3: Allocate output atlas buffer (RGBA8)
883 std::vector<uint8_t> atlasData(size * size * 4, 0);
884
885 // Step 4: Mipmaps section
886 {
887 const int levels = calcLevels(256) - calcLevels(4);
888 int rectX = 0, rectY = 0, rectW = size, rectH = size / 2;
889
890 for (int i = 0; i <= levels; ++i) {
891 if (rectW < 1 || rectH < 1) break;
892 spdlog::debug("EnvLighting: mipmap level {} -> rect({}, {}, {}, {})", i, rectX, rectY, rectW, rectH);
893
894 writeEquirectFromSource(atlasData.data(), size, rectX, rectY, rectW, rectH,
895 srcData, srcW, srcH);
896
897 rectX += rectH;
898 rectY += rectH;
899 rectW = std::max(1, rectW / 2);
900 rectH = std::max(1, rectH / 2);
901 }
902 }
903
904 // Step 5: Reflections section (GGX prefiltered)
905 {
906 int rectX = 0, rectY = size / 2, rectW = size / 2, rectH = size / 4;
907
908 for (int i = 1; i <= 6; ++i) {
909 if (rectW < 1 || rectH < 1) break;
910 const int specularPower = std::max(1, 2048 >> (i * 2));
911 spdlog::info("EnvLighting: GGX blur level {} (specularPower={}) -> rect({}, {}, {}, {})",
912 i, specularPower, rectX, rectY, rectW, rectH);
913 writeGGXRegion(atlasData.data(), size, rectX, rectY, rectW, rectH,
914 cubemap, specularPower, numReflectionSamples);
915 rectY += rectH;
916 rectW = std::max(1, rectW / 2);
917 rectH = std::max(1, rectH / 2);
918 }
919 }
920
921 // Step 6: Ambient section (Lambert irradiance)
922 {
923 const int rectX = size / 4;
924 const int rectY = size / 2 + size / 4;
925 const int rectW = size / 8;
926 const int rectH = size / 16;
927 spdlog::info("EnvLighting: Lambert ambient -> rect({}, {}, {}, {})", rectX, rectY, rectW, rectH);
928 writeLambertRegion(atlasData.data(), size, rectX, rectY, rectW, rectH,
929 cubemap, numAmbientSamples);
930 }
931
932 spdlog::info("EnvLighting: atlas generated successfully ({}x{}, RGBP, CPU-only)", size, size);
933 return atlasData;
934 }
935
936 // ----------------------------------------------------------------
937 // generateSkyboxCubemap
938 // EnvLighting.generateSkyboxCubemap()
939 // Creates a high-res cubemap from equirectangular HDR source.
940 // ----------------------------------------------------------------
942 {
943 if (!device || !source) {
944 spdlog::warn("EnvLighting::generateSkyboxCubemap: null device or source");
945 return nullptr;
946 }
947
948 const auto* srcData = static_cast<const float*>(source->getLevel(0));
949 if (!srcData) {
950 spdlog::warn("EnvLighting::generateSkyboxCubemap: source has no pixel data");
951 return nullptr;
952 }
953
954 const int srcW = static_cast<int>(source->width());
955 const int srcH = static_cast<int>(source->height());
956
957 // default size = source.width / 4 for equirect
958 if (size <= 0) {
959 size = srcW / 4;
960 }
961 // Clamp to reasonable range
962 size = std::clamp(size, 4, 4096);
963
964 spdlog::info("EnvLighting: generating {}x{} skybox cubemap from {}x{} equirect source",
965 size, size, srcW, srcH);
966
967 const size_t facePixels = static_cast<size_t>(size) * static_cast<size_t>(size);
968 const size_t faceBytes = facePixels * 4; // RGBA8
969
970 // For each face, compute the direction for each pixel, sample the equirect source,
971 // and convert from linear HDR to sRGB RGBA8.
972 TextureOptions options;
973 options.name = "skyboxCubemap";
974 options.width = size;
975 options.height = size;
977 options.cubemap = true;
978 options.mipmaps = false;
981
982 auto* texture = new Texture(device, options);
983 texture->setEncoding(TextureEncoding::RGBP);
984
985 for (int face = 0; face < 6; ++face) {
986 std::vector<uint8_t> faceData(faceBytes);
987
988 for (int py = 0; py < size; ++py) {
989 for (int px = 0; px < size; ++px) {
990 // Compute face UV (pixel center)
991 const float u = (static_cast<float>(px) + 0.5f) / static_cast<float>(size);
992 const float v = (static_cast<float>(py) + 0.5f) / static_cast<float>(size);
993
994 // Face UV -> world direction
995 float dx, dy, dz;
996 faceUvToDir(face, u, v, dx, dy, dz);
997
998 // Direction -> equirect UV
999 float srcU, srcV;
1000 dirToEquirectUv(dx, dy, dz, srcU, srcV);
1001
1002 // Wrap horizontally, clamp vertically
1003 srcU = srcU - std::floor(srcU);
1004 srcV = std::clamp(srcV, 0.0f, 1.0f);
1005
1006 // Bilinear sample from source equirect (RGBA32F)
1007 const float fx = srcU * static_cast<float>(srcW - 1);
1008 const float fy = srcV * static_cast<float>(srcH - 1);
1009
1010 const int x0 = std::clamp(static_cast<int>(std::floor(fx)), 0, srcW - 1);
1011 const int y0 = std::clamp(static_cast<int>(std::floor(fy)), 0, srcH - 1);
1012 const int x1 = (x0 + 1) % srcW;
1013 const int y1 = std::min(y0 + 1, srcH - 1);
1014
1015 const float sx = fx - static_cast<float>(x0);
1016 const float sy = fy - static_cast<float>(y0);
1017
1018 auto pixel = [&](int ppx, int ppy) -> const float* {
1019 return &srcData[(ppy * srcW + ppx) * 4];
1020 };
1021
1022 const float* p00 = pixel(x0, y0);
1023 const float* p10 = pixel(x1, y0);
1024 const float* p01 = pixel(x0, y1);
1025 const float* p11 = pixel(x1, y1);
1026
1027 float r = 0.0f, g = 0.0f, b = 0.0f;
1028 for (int c = 0; c < 3; ++c) {
1029 const float top = p00[c] * (1.0f - sx) + p10[c] * sx;
1030 const float bot = p01[c] * (1.0f - sx) + p11[c] * sx;
1031 const float val = top * (1.0f - sy) + bot * sy;
1032 if (c == 0) r = val;
1033 else if (c == 1) g = val;
1034 else b = val;
1035 }
1036
1037 // Tonemap: clamp HDR to [0, 1] with simple reinhard
1038 // Upstream uses numSamples=1024 reprojectTexture which is essentially
1039 // a direct copy. The skybox shader applies exposure + tonemapping at runtime,
1040 // so we store linear clamped to displayable range.
1041 // Use RGBP encoding for HDR preservation (same as envAtlas).
1042 const auto encoded = encodeRGBP(r, g, b);
1043
1044 const size_t offset = (static_cast<size_t>(py) * size + px) * 4;
1045 faceData[offset + 0] = encoded[0];
1046 faceData[offset + 1] = encoded[1];
1047 faceData[offset + 2] = encoded[2];
1048 faceData[offset + 3] = encoded[3];
1049 }
1050 }
1051
1052 texture->setLevelData(0, faceData.data(), faceData.size(), face);
1053 }
1054
1055 texture->upload();
1056
1057 spdlog::info("EnvLighting: skybox cubemap generated successfully ({}x{} per face, RGBP)", size, size);
1058
1059 return texture;
1060 }
1061}
static Texture * generateAtlas(GraphicsDevice *device, Texture *source, int size=512, int numReflectionSamples=1024, int numAmbientSamples=2048)
static std::vector< uint8_t > generateAtlasRaw(const float *srcData, int srcW, int srcH, int size=512, int numReflectionSamples=1024, int numAmbientSamples=2048)
static Texture * generateSkyboxCubemap(GraphicsDevice *device, Texture *source, int size=0)
Abstract GPU interface for resource creation, state management, and draw submission.
static float radicalInverse(uint32_t i)
Definition random.h:50
GPU texture resource supporting 2D, cubemap, volume, and array formats with mipmap management.
Definition texture.h:57
uint32_t width() const
Definition texture.h:63
void * getLevel(uint32_t mipLevel) const
Definition texture.cpp:147
uint32_t height() const
Definition texture.h:65