VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
worldClusters.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4//
5
6#include "worldClusters.h"
7
8#include <algorithm>
9#include <cmath>
10#include <cstring>
11#include <numbers>
12
13#include "spdlog/spdlog.h"
14
15namespace visutwin::canvas
16{
18 : _config(config)
19 {
20 const size_t cellCount = static_cast<size_t>(_config.totalCells());
21 const size_t cellDataSize = cellCount * static_cast<size_t>(_config.maxLightsPerCell);
22 _cellData.resize(cellDataSize, 0u);
23 }
24
26 {
27 const auto range = boundsRange();
28 const float rx = range.getX() > 1e-6f ? static_cast<float>(_config.cellsX) / range.getX() : 0.0f;
29 const float ry = range.getY() > 1e-6f ? static_cast<float>(_config.cellsY) / range.getY() : 0.0f;
30 const float rz = range.getZ() > 1e-6f ? static_cast<float>(_config.cellsZ) / range.getZ() : 0.0f;
31 return {rx, ry, rz};
32 }
33
34 void WorldClusters::update(const std::vector<ClusterLightData>& localLights,
35 const BoundingBox& cameraBounds)
36 {
37 _warnedOverflow = false;
38
39 collectLights(localLights);
40 computeGridBounds(cameraBounds);
41 assignLightsToCells();
42 packGpuLights();
43 }
44
45 void WorldClusters::collectLights(const std::vector<ClusterLightData>& localLights)
46 {
47 _lights.clear();
48 _lights.reserve(std::min(localLights.size(), static_cast<size_t>(255)));
49
50 const auto toRadians = [](const float degrees) {
51 return degrees * (std::numbers::pi_v<float> / 180.0f);
52 };
53
54 for (size_t i = 0; i < localLights.size() && _lights.size() < 255; ++i) {
55 const auto& ld = localLights[i];
56
57 if (ld.intensity <= 0.0f || ld.range <= 0.0f) {
58 continue;
59 }
60
61 LightEntry entry;
62 entry.data = ld;
63 entry.outerConeCos = std::cos(toRadians(std::max(ld.outerConeAngle, 0.0f) * 0.5f));
64 entry.innerConeCos = std::cos(toRadians(std::max(ld.innerConeAngle, 0.0f) * 0.5f));
65 if (entry.innerConeCos < entry.outerConeCos) {
66 entry.innerConeCos = entry.outerConeCos;
67 }
68
69 // Compute world-space AABB for grid assignment.
70 if (ld.isSpot) {
71 // Spot light: cone-shaped AABB.
72 // Approximate: the cone fits in a sphere of radius=range centered at
73 // a point offset from the light position along the light direction.
74 // For simplicity, use the full sphere AABB (slightly conservative).
75 const float r = ld.range;
76 entry.aabb = BoundingBox(ld.position, Vector3(r, r, r));
77 } else {
78 // Point/omni light: sphere AABB.
79 const float r = ld.range;
80 entry.aabb = BoundingBox(ld.position, Vector3(r, r, r));
81 }
82
83 _lights.push_back(entry);
84 }
85 }
86
87 void WorldClusters::computeGridBounds(const BoundingBox& cameraBounds)
88 {
89 if (_lights.empty()) {
90 _boundsMin = cameraBounds.center() - cameraBounds.halfExtents();
91 _boundsMax = cameraBounds.center() + cameraBounds.halfExtents();
92 } else {
93 // Start with camera bounds, expand to include all light AABBs.
94 Vector3 bMin = cameraBounds.center() - cameraBounds.halfExtents();
95 Vector3 bMax = cameraBounds.center() + cameraBounds.halfExtents();
96
97 for (const auto& light : _lights) {
98 const auto lightMin = light.aabb.center() - light.aabb.halfExtents();
99 const auto lightMax = light.aabb.center() + light.aabb.halfExtents();
100
101 bMin = Vector3(
102 std::min(bMin.getX(), lightMin.getX()),
103 std::min(bMin.getY(), lightMin.getY()),
104 std::min(bMin.getZ(), lightMin.getZ())
105 );
106 bMax = Vector3(
107 std::max(bMax.getX(), lightMax.getX()),
108 std::max(bMax.getY(), lightMax.getY()),
109 std::max(bMax.getZ(), lightMax.getZ())
110 );
111 }
112
113 _boundsMin = bMin;
114 _boundsMax = bMax;
115 }
116
117 // Add small epsilon padding to prevent division by zero.
118 constexpr float eps = 0.001f;
119 const auto range = _boundsMax - _boundsMin;
120 if (range.getX() < eps) { _boundsMax = Vector3(_boundsMax.getX() + eps, _boundsMax.getY(), _boundsMax.getZ()); }
121 if (range.getY() < eps) { _boundsMax = Vector3(_boundsMax.getX(), _boundsMax.getY() + eps, _boundsMax.getZ()); }
122 if (range.getZ() < eps) { _boundsMax = Vector3(_boundsMax.getX(), _boundsMax.getY(), _boundsMax.getZ() + eps); }
123 }
124
125 void WorldClusters::assignLightsToCells()
126 {
127 const size_t cellCount = static_cast<size_t>(_config.totalCells());
128 const int maxPerCell = _config.maxLightsPerCell;
129 const size_t cellDataSize = cellCount * static_cast<size_t>(maxPerCell);
130
131 // Resize and clear cell data.
132 if (_cellData.size() != cellDataSize) {
133 _cellData.resize(cellDataSize);
134 }
135 std::memset(_cellData.data(), 0, _cellData.size());
136
137 if (_lights.empty()) {
138 return;
139 }
140
141 const auto range = boundsRange();
142 const float invRangeX = range.getX() > 1e-6f ? 1.0f / range.getX() : 0.0f;
143 const float invRangeY = range.getY() > 1e-6f ? 1.0f / range.getY() : 0.0f;
144 const float invRangeZ = range.getZ() > 1e-6f ? 1.0f / range.getZ() : 0.0f;
145
146 // Track light count per cell for fast insertion.
147 // Use a temporary vector since we only need counts during assignment.
148 std::vector<int> cellCounts(cellCount, 0);
149
150 for (int lightIdx = 0; lightIdx < static_cast<int>(_lights.size()); ++lightIdx) {
151 const auto& light = _lights[lightIdx];
152
153 // Convert light AABB to cell coordinates.
154 const auto lMin = light.aabb.center() - light.aabb.halfExtents();
155 const auto lMax = light.aabb.center() + light.aabb.halfExtents();
156
157 int cellMinX = static_cast<int>(std::floor((lMin.getX() - _boundsMin.getX()) * invRangeX * static_cast<float>(_config.cellsX)));
158 int cellMinY = static_cast<int>(std::floor((lMin.getY() - _boundsMin.getY()) * invRangeY * static_cast<float>(_config.cellsY)));
159 int cellMinZ = static_cast<int>(std::floor((lMin.getZ() - _boundsMin.getZ()) * invRangeZ * static_cast<float>(_config.cellsZ)));
160 int cellMaxX = static_cast<int>(std::floor((lMax.getX() - _boundsMin.getX()) * invRangeX * static_cast<float>(_config.cellsX)));
161 int cellMaxY = static_cast<int>(std::floor((lMax.getY() - _boundsMin.getY()) * invRangeY * static_cast<float>(_config.cellsY)));
162 int cellMaxZ = static_cast<int>(std::floor((lMax.getZ() - _boundsMin.getZ()) * invRangeZ * static_cast<float>(_config.cellsZ)));
163
164 // Clamp to valid cell range.
165 cellMinX = std::clamp(cellMinX, 0, _config.cellsX - 1);
166 cellMinY = std::clamp(cellMinY, 0, _config.cellsY - 1);
167 cellMinZ = std::clamp(cellMinZ, 0, _config.cellsZ - 1);
168 cellMaxX = std::clamp(cellMaxX, 0, _config.cellsX - 1);
169 cellMaxY = std::clamp(cellMaxY, 0, _config.cellsY - 1);
170 cellMaxZ = std::clamp(cellMaxZ, 0, _config.cellsZ - 1);
171
172 // Store light index (1-based) in each overlapping cell.
173 const uint8_t lightIdx1 = static_cast<uint8_t>(lightIdx + 1);
174
175 for (int y = cellMinY; y <= cellMaxY; ++y) {
176 for (int z = cellMinZ; z <= cellMaxZ; ++z) {
177 for (int x = cellMinX; x <= cellMaxX; ++x) {
178 const int cellIndex = y * _config.cellsX * _config.cellsZ
179 + z * _config.cellsX
180 + x;
181 const int count = cellCounts[cellIndex];
182 if (count < maxPerCell) {
183 _cellData[static_cast<size_t>(cellIndex * maxPerCell + count)] = lightIdx1;
184 cellCounts[cellIndex] = count + 1;
185 } else if (!_warnedOverflow) {
186 spdlog::warn("WorldClusters: cell ({},{},{}) exceeded maxLightsPerCell={}, some lights dropped",
187 x, y, z, maxPerCell);
188 _warnedOverflow = true;
189 }
190 }
191 }
192 }
193 }
194 }
195
196 void WorldClusters::packGpuLights()
197 {
198 _gpuLights.resize(_lights.size());
199
200 for (size_t i = 0; i < _lights.size(); ++i) {
201 const auto& entry = _lights[i];
202 const auto& ld = entry.data;
203 auto& gpu = _gpuLights[i];
204
205 gpu.positionRange[0] = ld.position.getX();
206 gpu.positionRange[1] = ld.position.getY();
207 gpu.positionRange[2] = ld.position.getZ();
208 gpu.positionRange[3] = ld.range;
209
210 gpu.directionSpot[0] = ld.direction.getX();
211 gpu.directionSpot[1] = ld.direction.getY();
212 gpu.directionSpot[2] = ld.direction.getZ();
213 gpu.directionSpot[3] = entry.outerConeCos;
214
215 // Convert sRGB color to linear for GPU.
216 const float r = std::pow(std::max(ld.color.r, 0.0f), 2.2f);
217 const float g = std::pow(std::max(ld.color.g, 0.0f), 2.2f);
218 const float b = std::pow(std::max(ld.color.b, 0.0f), 2.2f);
219 gpu.colorIntensity[0] = r;
220 gpu.colorIntensity[1] = g;
221 gpu.colorIntensity[2] = b;
222 gpu.colorIntensity[3] = ld.intensity;
223
224 gpu.params[0] = entry.innerConeCos;
225 gpu.params[1] = ld.isSpot ? 1.0f : 0.0f;
226 gpu.params[2] = ld.falloffModeLinear ? 1.0f : 0.0f;
227 gpu.params[3] = 0.0f;
228 }
229 }
230}
Axis-Aligned Bounding Box defined by center and half-extents.
Definition boundingBox.h:21
void update(const std::vector< ClusterLightData > &localLights, const BoundingBox &cameraBounds)
const ClusterConfig & config() const
WorldClusters(const ClusterConfig &config=ClusterConfig{})
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29