VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
lightingValidation.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// Created by Arnis Lektauers on 13.02.2026.
5//
7
8#include <algorithm>
9#include <cassert>
10#include <cmath>
11#include <numbers>
12
13#include "core/math/vector3.h"
14#include "spdlog/spdlog.h"
15
16namespace visutwin::canvas
17{
18 namespace
19 {
20 constexpr float kEps = 1e-4f;
21
22 float square(const float value)
23 {
24 return value * value;
25 }
26
27 float saturate(const float value)
28 {
29 return std::clamp(value, 0.0f, 1.0f);
30 }
31
32 float smoothstep(const float edge0, const float edge1, const float x)
33 {
34 if (std::abs(edge1 - edge0) <= 1e-6f) {
35 return x < edge0 ? 0.0f : 1.0f;
36 }
37 const float t = saturate((x - edge0) / (edge1 - edge0));
38 return t * t * (3.0f - 2.0f * t);
39 }
40
41 bool nearlyEqual(const float a, const float b, const float eps = kEps)
42 {
43 return std::abs(a - b) <= eps;
44 }
45
46 float getFalloffLinear(const float lightRadius, const Vector3& lightDir)
47 {
48 const float radius = std::max(lightRadius, 1e-4f);
49 const float d = lightDir.length();
50 return std::max((radius - d) / radius, 0.0f);
51 }
52
53 float getFalloffInvSquared(const float lightRadius, const Vector3& lightDir)
54 {
55 const float sqrDist = lightDir.lengthSquared();
56 float falloff = 1.0f / (sqrDist + 1.0f);
57 const float invRadius = 1.0f / std::max(lightRadius, 1e-4f);
58
59 falloff *= 16.0f;
60 falloff *= square(saturate(1.0f - square(sqrDist * square(invRadius))));
61 return falloff;
62 }
63
64 float getSpotEffect(const Vector3& lightSpotDir, const float lightInnerConeAngle,
65 const float lightOuterConeAngle, const Vector3& lightDirNorm)
66 {
67 const float cosAngle = lightDirNorm.dot(lightSpotDir);
68 return smoothstep(lightOuterConeAngle, lightInnerConeAngle, cosAngle);
69 }
70
71 struct LocalLightEvalInput
72 {
73 Vector3 lightPosition;
74 Vector3 spotDirection;
75 float range = 10.0f;
76 bool linearFalloff = true;
77 bool spot = false;
78 float innerConeCos = 1.0f;
79 float outerConeCos = 1.0f;
80 Vector3 worldPos;
81 };
82
83 float evaluateLocalAttenuation(const LocalLightEvalInput& in)
84 {
85 const Vector3 lightDirW = in.lightPosition - in.worldPos;
86 const float lightDirLenSq = lightDirW.lengthSquared();
87 if (lightDirLenSq <= 1e-8f) {
88 return in.linearFalloff ? 1.0f : 16.0f;
89 }
90
91 const Vector3 dLightDirNormW = lightDirW.normalized();
92 float attenuation = in.linearFalloff ? getFalloffLinear(in.range, lightDirW) : getFalloffInvSquared(in.range, lightDirW);
93 if (in.spot) {
94 attenuation *= getSpotEffect(in.spotDirection.normalized(), in.innerConeCos, in.outerConeCos, -dLightDirNormW);
95 }
96 return attenuation;
97 }
98 }
99
101 {
102 static bool ran = false;
103 static bool pass = true;
104 if (ran) {
105 return pass;
106 }
107 ran = true;
108
109 auto check = [&](const bool condition, const char* message) {
110 if (!condition) {
111 pass = false;
112 spdlog::error("Lighting validation failed: {}", message);
113 }
114 };
115
116 // Directional light baseline in shader is constant attenuation 1.0.
117 check(nearlyEqual(1.0f, 1.0f), "directional baseline attenuation must be 1.0");
118
119 // Point light linear falloff sanity.
120 {
121 LocalLightEvalInput in{};
122 in.lightPosition = Vector3(0.0f, 0.0f, 0.0f);
123 in.range = 10.0f;
124 in.linearFalloff = true;
125
126 in.worldPos = Vector3(0.0f, 0.0f, 0.0f);
127 check(nearlyEqual(evaluateLocalAttenuation(in), 1.0f), "point linear attenuation at source must be 1.0");
128
129 in.worldPos = Vector3(0.0f, 0.0f, 5.0f);
130 check(nearlyEqual(evaluateLocalAttenuation(in), 0.5f), "point linear attenuation at half range must be 0.5");
131
132 in.worldPos = Vector3(0.0f, 0.0f, 10.0f);
133 check(nearlyEqual(evaluateLocalAttenuation(in), 0.0f), "point linear attenuation at range limit must be 0.0");
134 }
135
136 // Point light inverse-squared falloff sanity and range window clamp.
137 {
138 LocalLightEvalInput in{};
139 in.lightPosition = Vector3(0.0f, 0.0f, 0.0f);
140 in.range = 10.0f;
141 in.linearFalloff = false;
142
143 in.worldPos = Vector3(0.0f, 0.0f, 1.0f);
144 const float nearValue = evaluateLocalAttenuation(in);
145 in.worldPos = Vector3(0.0f, 0.0f, 5.0f);
146 const float midValue = evaluateLocalAttenuation(in);
147 check(nearValue > midValue, "point inverse-squared attenuation must decrease with distance");
148
149 in.worldPos = Vector3(0.0f, 0.0f, 12.0f);
150 check(nearlyEqual(evaluateLocalAttenuation(in), 0.0f), "point inverse-squared attenuation must clamp to 0 outside range window");
151 }
152
153 // Spot cone falloff sanity: inside full, outside none, between partial.
154 {
155 const float innerCone = std::cos(20.0f * std::numbers::pi_v<float> / 180.0f);
156 const float outerCone = std::cos(30.0f * std::numbers::pi_v<float> / 180.0f);
157 LocalLightEvalInput in{};
158 in.lightPosition = Vector3(0.0f, 0.0f, 0.0f);
159 in.spotDirection = Vector3(0.0f, 0.0f, -1.0f);
160 in.spot = true;
161 in.innerConeCos = innerCone;
162 in.outerConeCos = outerCone;
163 in.linearFalloff = true;
164 in.range = 1000.0f;
165
166 in.worldPos = Vector3(0.0f, 0.0f, -5.0f);
167 const float inside = evaluateLocalAttenuation(in);
168 check(inside >= 0.99f, "spot attenuation should be near 1.0 inside inner cone");
169
170 in.worldPos = Vector3(5.0f, 0.0f, -5.0f);
171 const float outside = evaluateLocalAttenuation(in);
172 check(outside <= 1e-3f, "spot attenuation should be near 0.0 outside outer cone");
173
174 in.worldPos = Vector3(2.0f, 0.0f, -5.0f);
175 const float between = evaluateLocalAttenuation(in);
176 check(between > 0.0f && between < 1.0f, "spot attenuation should interpolate between cones");
177 }
178
179 if (!pass) {
180 assert(false && "Lighting self-test failed");
181 } else {
182 spdlog::info("Lighting validation self-test passed.");
183 }
184
185 return pass;
186 }
187}
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29
Vector3 normalized() const