Skip to content

Commit 12ea131

Browse files
committed
Add light map baker
1 parent 654c56b commit 12ea131

File tree

7 files changed

+1647
-6
lines changed

7 files changed

+1647
-6
lines changed

Sources/GateEngine/Resources/Lights/Baking/LightMapBaker.swift

Lines changed: 700 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
/*
2+
* Copyright © 2025 Dustin Collins (Strega's Gate)
3+
* All Rights Reserved.
4+
*
5+
* http://stregasgate.com
6+
*/
7+
8+
public struct LightMapPacker: Sendable {
9+
public let uvSet: Int
10+
public let texelDensity: Int
11+
public let options: LightMapBaker.Source.Options
12+
13+
public struct Options: OptionSet, Equatable, Hashable, Sendable {
14+
public let rawValue: UInt8
15+
public init(rawValue: UInt8) {
16+
self.rawValue = rawValue
17+
}
18+
19+
/// No options
20+
public static let none: Self = []
21+
22+
/// The output texture size will have an aspect ration 1:1 and width will be equal to height
23+
public static let allowRotation: Self = Self(rawValue: 1 << 0)
24+
25+
public static var optimize: Self {
26+
return [.allowRotation, ]
27+
}
28+
}
29+
30+
/**
31+
- parameter uvSet: The uvSet index to replace when packing geometry. Index 0 is uvSet1.
32+
- parameter texelDensity: The desired number of lightmap texels per unit.
33+
- parameter optimize: When true additional compute will be used to reduce the resulting lightmap size by packing UVs more efficiently.
34+
*/
35+
public init(uvSet: Int = 1, texelDensity: Int, options: LightMapBaker.Source.Options) {
36+
self.uvSet = uvSet
37+
self.texelDensity = texelDensity
38+
self.options = options
39+
}
40+
41+
struct PackedTriangle {
42+
let texelDensity: Int
43+
let original: Triangle
44+
let unwrapped: UnwrappedTriangle
45+
let options: LightMapBaker.Source.Options
46+
47+
@inlinable
48+
var p1: Position2f {
49+
return unwrapped.p1
50+
}
51+
@inlinable
52+
var p2: Position2f {
53+
return unwrapped.p2
54+
}
55+
@inlinable
56+
var p3: Position2f {
57+
return unwrapped.p3
58+
}
59+
60+
@inlinable
61+
var minTextureSize: Size2f {
62+
return Size2f(options.minimumTexels)
63+
}
64+
65+
@inlinable
66+
var textureSize: Size2f {
67+
let minX = Swift.min(p1.x, p2.x, p3.x)
68+
let maxX = Swift.max(p1.x, p2.x, p3.x)
69+
70+
let minY = Swift.min(p1.y, p2.y, p3.y)
71+
let maxY = Swift.max(p1.y, p2.y, p3.y)
72+
73+
let width = abs(minX.distance(to: maxX))
74+
let height = abs(minY.distance(to: maxY))
75+
76+
var size = Size2f(width: width, height: height) * Float(texelDensity)
77+
78+
let minTextureSize = self.minTextureSize
79+
if size.x < minTextureSize.width {
80+
size.x = minTextureSize.width
81+
}
82+
if size.y < minTextureSize.height {
83+
size.y = minTextureSize.height
84+
}
85+
86+
// Round up
87+
size += size.truncatingRemainder(dividingBy: 2.0)
88+
89+
return size
90+
}
91+
92+
private var _max: Position2f {
93+
return Position2f(
94+
x: Swift.max(Float(self.p1.x), Float(self.p2.x), Float(self.p3.x)),
95+
y: Swift.max(Float(self.p1.y), Float(self.p2.y), Float(self.p3.y))
96+
)
97+
}
98+
99+
var v1UV: Position2f {
100+
return Position2f(self.p1) / _max
101+
}
102+
var v2UV: Position2f {
103+
return Position2f(self.p2) / _max
104+
}
105+
var v3UV: Position2f {
106+
return Position2f(self.p3) / _max
107+
}
108+
109+
var atlasTexture: AtlasTexture {
110+
return AtlasTexture(size: Size2i(self.textureSize))
111+
}
112+
113+
init(triangle: Triangle, texelDensity: Int, options: LightMapBaker.Source.Options) {
114+
self.init(
115+
original: triangle,
116+
unwrappedTriangle: UnwrappedTriangle(triangle: triangle, texelDensity: texelDensity, options: options.packing),
117+
texelDensity: texelDensity,
118+
options: options
119+
)
120+
}
121+
122+
private init(original: Triangle, unwrappedTriangle: UnwrappedTriangle, texelDensity: Int, options: LightMapBaker.Source.Options) {
123+
self.original = original
124+
self.texelDensity = texelDensity
125+
self.unwrapped = unwrappedTriangle
126+
self.options = options
127+
}
128+
}
129+
130+
struct UnwrappedTriangle {
131+
let p1: Position2f
132+
let p2: Position2f
133+
let p3: Position2f
134+
135+
init(triangle: Triangle, texelDensity: Int, options: Options) {
136+
// Project the triangle onto a 2D plane with the face normal pointing toward the viewer
137+
var projectionRotation: Quaternion = Quaternion(direction: -triangle.faceNormal)
138+
139+
#if false // TODO: Implement smart rotation and scaling
140+
if options.contains(.allowRotation) {
141+
// Find the triangles longest edge
142+
let longestLine: Line3D = {
143+
let line1 = Line3D(triangle.v1.position, triangle.v2.position)
144+
let line2 = Line3D(triangle.v2.position, triangle.v3.position)
145+
let line3 = Line3D(triangle.v3.position, triangle.v1.position)
146+
let sortedLines = [
147+
(index: 0, length: line1.length),
148+
(index: 1, length: line2.length),
149+
(index: 2, length: line3.length)
150+
].sorted(by: { lhs, rhs in
151+
return lhs.length >= rhs.length
152+
})
153+
154+
switch sortedLines[0].index {
155+
case 0:
156+
return line1
157+
case 1:
158+
return line2
159+
default:
160+
return line3
161+
}
162+
}()
163+
164+
// Find a rotation so the longest side runs down the bottom left to top right of a bounding box
165+
let longestTowardCenter = Direction3(from: longestLine.center, to: triangle.center)
166+
let bottomRightToTopLeft = Direction3(from: Position3(1, 1, 0), to: Position3(0, 0, 0))
167+
let angleRotation = Quaternion(
168+
longestTowardCenter.angle(to: bottomRightToTopLeft),
169+
axis: -triangle.faceNormal
170+
)
171+
172+
// Combine rotations
173+
projectionRotation *= angleRotation
174+
}
175+
#endif
176+
177+
// Unwrap the triangle
178+
let projectedP1 = triangle.v1.position.rotated(around: triangle.center, by: projectionRotation)
179+
let projectedP2 = triangle.v2.position.rotated(around: triangle.center, by: projectionRotation)
180+
let projectedP3 = triangle.v3.position.rotated(around: triangle.center, by: projectionRotation)
181+
182+
// Offset the projected points
183+
let p1 = Position2f(x: projectedP1.x, y: projectedP1.y)
184+
let p2 = Position2f(x: projectedP2.x, y: projectedP2.y)
185+
let p3 = Position2f(x: projectedP3.x, y: projectedP3.y)
186+
187+
// Find an offset that can move the points so the resulting bounding box has a top left point of zero
188+
let offset = Position2f(
189+
x: Swift.min(p1.x, p2.x, p3.x),
190+
y: Swift.min(p1.y, p2.y, p3.y)
191+
)
192+
193+
self.p1 = p1 - offset
194+
self.p2 = p2 - offset
195+
self.p3 = p3 - offset
196+
}
197+
}
198+
199+
public struct AtlasTexture {
200+
public let size: Size2i
201+
}
202+
203+
public struct AtlasTextureLuxel {
204+
let position: Position3f
205+
let texturePixel: Position2i
206+
}
207+
208+
/// Moves each individual triangle's UVs into it's own 0 to 1 space, after baking the textures use the TextureAtlasBuilder to pack inot a single atlas
209+
public func atlasPack(_ rawGeometry: inout RawGeometry) -> [AtlasTexture] {
210+
var textures: [AtlasTexture] = []
211+
textures.reserveCapacity(rawGeometry.count)
212+
213+
for index in rawGeometry.indices {
214+
let packed = PackedTriangle(triangle: rawGeometry[index], texelDensity: texelDensity, options: options)
215+
216+
textures.append(packed.atlasTexture)
217+
218+
let pixelSize: Size2f = Size2f.one / Size2f(packed.atlasTexture.size)
219+
let halfSize: Size2f = pixelSize * 0.5
220+
221+
// let textureSize = Size2f(packed.atlasTexture.size)
222+
223+
var uv1 = packed.v1UV
224+
var uv2 = packed.v2UV
225+
var uv3 = packed.v3UV
226+
227+
if uv1.x < pixelSize.width + halfSize.width {
228+
uv1.x = pixelSize.width + halfSize.width
229+
}
230+
if uv1.x > 1 - (pixelSize.width + halfSize.width) {
231+
uv1.x = 1 - (pixelSize.width + halfSize.width)
232+
}
233+
if uv1.y < pixelSize.height + halfSize.width {
234+
uv1.y = pixelSize.height + halfSize.width
235+
}
236+
if uv1.y > 1 - (pixelSize.height + halfSize.width) {
237+
uv1.y = 1 - (pixelSize.height + halfSize.width)
238+
}
239+
240+
if uv2.x < pixelSize.width + halfSize.width {
241+
uv2.x = pixelSize.width + halfSize.width
242+
}
243+
if uv2.x > 1 - (pixelSize.width + halfSize.width) {
244+
uv2.x = 1 - (pixelSize.width + halfSize.width)
245+
}
246+
if uv2.y < pixelSize.height + halfSize.width {
247+
uv2.y = pixelSize.height + halfSize.width
248+
}
249+
if uv2.y > 1 - (pixelSize.height + halfSize.width) {
250+
uv2.y = 1 - (pixelSize.height + halfSize.width)
251+
}
252+
253+
if uv3.x < pixelSize.width + halfSize.width {
254+
uv3.x = pixelSize.width + halfSize.width
255+
}
256+
if uv3.x > 1 - (pixelSize.width + halfSize.width) {
257+
uv3.x = 1 - (pixelSize.width + halfSize.width)
258+
}
259+
if uv3.y < pixelSize.height + halfSize.width {
260+
uv3.y = pixelSize.height + halfSize.width
261+
}
262+
if uv3.y > 1 - (pixelSize.height + halfSize.width) {
263+
uv3.y = 1 - (pixelSize.height + halfSize.width)
264+
}
265+
266+
switch uvSet {
267+
case 0:
268+
rawGeometry[index].v1.uv1 = TextureCoordinate(uv1)
269+
rawGeometry[index].v2.uv1 = TextureCoordinate(uv2)
270+
rawGeometry[index].v3.uv1 = TextureCoordinate(uv3)
271+
case 1:
272+
rawGeometry[index].v1.uv2 = TextureCoordinate(uv1)
273+
rawGeometry[index].v2.uv2 = TextureCoordinate(uv2)
274+
rawGeometry[index].v3.uv2 = TextureCoordinate(uv3)
275+
default:
276+
fatalError("Only uvSet 0 and 1 are supported for now.")
277+
}
278+
}
279+
280+
return textures
281+
}
282+
}
283+
284+
285+
286+
extension LightMapPacker {
287+
struct SearchGrid {
288+
var rows: [[Bool]] = []
289+
var width: Int {
290+
return rows.first?.count ?? 0
291+
}
292+
var height: Int {
293+
return rows.count
294+
}
295+
296+
mutating func markAsOccupied(_ occupied: Bool, _ rect: Rect2i) {
297+
// Insert new rows
298+
while rows.count < rect.y + rect.height {
299+
rows.append(Array(repeating: false, count: self.width))
300+
}
301+
// Insert new columns
302+
while rows[0].count < rect.x + rect.width {
303+
for rowIndex in rows.indices {
304+
rows[rowIndex].append(false)
305+
}
306+
}
307+
// Mark occupied
308+
for row in rect.y ..< rect.y + rect.height {
309+
for column in rect.x ..< rect.x + rect.width {
310+
rows[row][column] = occupied
311+
}
312+
}
313+
}
314+
315+
func isOccupied(_ rect: Rect2i) -> Bool {
316+
if rect.x < self.width && rect.y < self.height {
317+
for row in rect.y ..< min(rect.y + rect.height, self.height) {
318+
for column in rect.x ..< min(rect.x + rect.width, self.width) {
319+
if rows[row][column] == true {
320+
// If any slot is occupied, the rectangle wont fit here
321+
return true
322+
}
323+
}
324+
}
325+
}
326+
return false
327+
}
328+
329+
mutating func firstUnoccupiedFor(_ size: Size2i, markOccupied: Bool) -> Position2i {
330+
for rowIndex in 0 ..< self.height {
331+
for columnIndex in 0 ..< self.width {
332+
guard columnIndex + width < self.width else { break }
333+
let rect = Rect2i(origin: Position2i(x: columnIndex, y: rowIndex), size: size)
334+
if self.isOccupied(rect) == false {
335+
if markOccupied {
336+
self.markAsOccupied(true, rect)
337+
}
338+
return Position2i(x: columnIndex, y: rowIndex)
339+
}
340+
}
341+
}
342+
343+
let coord: Position2i
344+
// Attempt to keep the search grid a square
345+
if self.width + size.width > self.height + size.height {
346+
// Prefer vertical expansion
347+
coord = Position2i(x: 0, y: self.height)
348+
}else{
349+
// Prefer horizontal expansion
350+
coord = Position2i(x: self.width, y: 0)
351+
}
352+
if markOccupied {
353+
self.markAsOccupied(true, Rect2i(origin: coord, size: size))
354+
}
355+
return coord
356+
}
357+
}
358+
}

0 commit comments

Comments
 (0)