Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Demos/Navigation/NavigationDemoMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ struct NavigationDemoMain {
let session = world.getResource(NavigationDemoSession.self),
let robotID = session.robot, let robot = world.getEntityByID(robotID) else { throw DemoFailure.sceneUnavailable }
let probe = Vector3(7.5, 0, 4.5)
let clip = camera.projection.makeClipView() * transform.matrix.inverse * Vector4(probe, 1)
let clip = camera.projection.makeClipView() * (transform.matrix.inverse * Vector4(probe, 1))
guard clip.w.isFinite, clip.w > 0 else { throw DemoFailure.sceneUnavailable }
let projected = clip.xyz / clip.w
guard projected.x.isFinite, projected.y.isFinite, projected.z >= 0, projected.z <= 1 else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Define `func build(model)`. Axes follow the engine: X right, Y up, Z forward. Ev
| `model.group()` | Empty part, used as a pivot or parent. |
| `model.place(id, x, y, z)` | Position relative to the parent. |
| `model.rotate(id, x, y, z)` | Degrees; roll (Z) is applied first, then pitch (X), then yaw (Y). |
| `model.scale(id, x, y, z)` | Positive factors only; mirroring is not supported. |
| `model.scale(id, x, y, z)` | Positive factors only; mirroring is not supported. The scale of a part without children is baked into its geometry, so squashed spheres (clouds, bushes) shade correctly; a scaled group scales its children and can distort their shading when the factors differ. |
| `model.paint(id, i)` | Material for the part. Material 0 is neutral gray unless you define it. |
| `model.parent(id, parentId)` | Attach to a part created earlier; transforms are inherited. |
| `model.name(id, "text")` | Name shown in the scene hierarchy and `editor.model.inspect`. |
Expand Down
31 changes: 30 additions & 1 deletion Sources/AdaAssets/Procedural/GLBWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,34 @@ public struct ProceduralModel: Sendable, Equatable {
}
}

extension ProceduralModel {
/// Applies positive, finite scales of parts without children to their vertices and normals, leaving those nodes unscaled.
///
/// A non-uniform node scale distorts normals unless the renderer uses the inverse transpose, which produces dark
/// patches on squashed spheres such as clouds. Baking the scale makes the model shade correctly everywhere.
/// Parts with children or nonpositive or nonfinite scale components keep their original mesh and node scale.
public func bakingLeafScales() -> ProceduralModel {
var result = self
let parents = Set(nodes.compactMap(\.parent))
for index in result.nodes.indices where !parents.contains(index) {
let scale = result.nodes[index].scale
guard scale != .one,
scale.x > 0, scale.y > 0, scale.z > 0,
scale.x.isFinite, scale.y.isFinite, scale.z.isFinite,
var mesh = result.nodes[index].mesh, mesh.positions.count == mesh.normals.count
else { continue }
let inverse = SIMD3<Float>(1 / scale.x, 1 / scale.y, 1 / scale.z)
for vertex in mesh.positions.indices {
mesh.positions[vertex] *= scale
mesh.normals[vertex] = vecNormalize(mesh.normals[vertex] * inverse)
}
result.nodes[index].mesh = mesh
result.nodes[index].scale = .one
}
return result
}
}

public enum GLBWritingError: Error, Equatable, Sendable {
case noGeometry
case invalidMaterial(node: Int)
Expand All @@ -60,7 +88,8 @@ public enum GLBWritingError: Error, Equatable, Sendable {
public struct GLBWriter: Sendable {
public init() {}

public func encode(_ model: ProceduralModel) throws -> Data {
public func encode(_ source: ProceduralModel) throws -> Data {
let model = source.bakingLeafScales()
guard model.nodes.contains(where: { $0.mesh?.isEmpty == false }) else { throw GLBWritingError.noGeometry }

var binary = Data()
Expand Down
80 changes: 80 additions & 0 deletions Tests/AdaAssetsTests/ProceduralModelTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Math
import Testing

@testable import AdaAssets
Expand Down Expand Up @@ -107,6 +108,85 @@ struct ProceduralModelTests {
#expect(gold.emissiveFactor.x == 0.1)
}

@Test("Scale of a part without children is baked into its geometry so normals stay correct")
func bakesLeafScale() throws {
var group = ProceduralNode(name: "Group")
group.scale = [2, 2, 2]
var cloud = ProceduralNode(name: "Cloud", mesh: ProceduralGeometry.sphere(radius: 1, segments: 16))
cloud.scale = [1.7, 0.55, 0.6]
cloud.parent = 0
var puff = ProceduralNode(name: "Puff", mesh: ProceduralGeometry.sphere(radius: 1, segments: 16))
puff.scale = [3, 1, 1]
let model = ProceduralModel(materials: [ProceduralMaterial(name: "Default")], nodes: [group, cloud, puff])

let baked = model.bakingLeafScales()
#expect(baked.nodes[1].scale == .one && baked.nodes[2].scale == .one)
#expect(baked.nodes[0].scale == SIMD3<Float>(2, 2, 2), "a parent keeps its scale")
let mesh = try #require(baked.nodes[2].mesh)
#expect(abs((mesh.positions.map(\.x).max() ?? 0) - 3) < 1e-4)
#expect(abs((mesh.positions.map(\.y).max() ?? 0) - 1) < 1e-4)
#expect(mesh.normals.allSatisfy { abs(vecLength($0) - 1) < 1e-4 })
// The pole of a stretched sphere still faces straight up, and the stretched equator faces along the long axis.
let top = try #require(mesh.positions.indices.max { mesh.positions[$0].y < mesh.positions[$1].y })
#expect(mesh.normals[top].y > 0.99)
// Every triangle still faces outward after scaling.
for start in stride(from: 0, to: mesh.indices.count, by: 3) {
let corners = (0..<3).map { Int(mesh.indices[start + $0]) }
let face = vecCross(mesh.positions[corners[1]] - mesh.positions[corners[0]], mesh.positions[corners[2]] - mesh.positions[corners[0]])
let centroid = corners.map { mesh.positions[$0] }.reduce(SIMD3<Float>.zero, +)
#expect((face * centroid).sum() > 0)
}

let imported = try NativeGLTFLoader().load(data: GLBWriter().encode(model))
let meshIndex = try #require(imported.nodes[2].meshIndex)
let primitive = try #require(imported.meshes[meshIndex].primitives.first)
let positions = try #require(primitive.attributes[.position]?.vector3Values())
#expect(abs((positions.map(\.x).max() ?? 0) - 3) < 1e-4, "the exported mesh carries the baked scale")
}

@Test("Mirrored, singular and nonfinite leaf scales preserve the original mesh and transform", arguments: [
SIMD3<Float>(-2, 1, 3), SIMD3<Float>(2, -1, 3), SIMD3<Float>(2, 1, -3),
SIMD3<Float>(-2, -1, 3), SIMD3<Float>(-2, -1, -3),
SIMD3<Float>(0, 1, 3), SIMD3<Float>(2, 0, 3), SIMD3<Float>(2, 1, 0),
SIMD3<Float>(.infinity, 1, 3), SIMD3<Float>(2, -.infinity, 3), SIMD3<Float>(2, 1, .nan),
])
func preservesUnsupportedLeafScale(scale: SIMD3<Float>) {
var node = ProceduralNode(name: "Leaf", mesh: ProceduralGeometry.box(width: 1, height: 2, depth: 3))
node.scale = scale
let model = ProceduralModel(materials: [ProceduralMaterial(name: "Default")], nodes: [node])

let baked = model.bakingLeafScales()
#expect(baked.nodes[0].mesh == node.mesh)
for axis in 0..<3 {
if scale[axis].isNaN {
#expect(baked.nodes[0].scale[axis].isNaN)
} else {
#expect(baked.nodes[0].scale[axis] == scale[axis])
}
}
}

@Test("GLB output preserves mirrored and singular leaf transforms and geometry", arguments: [
SIMD3<Float>(-2, 1, 3), SIMD3<Float>(-2, -1, 3), SIMD3<Float>(-2, -1, -3),
SIMD3<Float>(0, 1, 3), SIMD3<Float>(2, 0, 3), SIMD3<Float>(2, 1, 0),
])
func unsupportedScaleRoundTrip(scale: SIMD3<Float>) throws {
let mesh = ProceduralGeometry.box(width: 1, height: 2, depth: 3)
var node = ProceduralNode(name: "Leaf", mesh: mesh)
node.scale = scale
let model = ProceduralModel(materials: [ProceduralMaterial(name: "Default")], nodes: [node])

let imported = try NativeGLTFLoader().load(data: GLBWriter().encode(model))
let importedNode = try #require(imported.nodes.first)
let restPose = try #require(importedNode.restPose)
#expect(restPose.scale.x == scale.x && restPose.scale.y == scale.y && restPose.scale.z == scale.z)
let meshIndex = try #require(importedNode.meshIndex)
let primitive = try #require(imported.meshes[meshIndex].primitives.first)
#expect(primitive.attributes[.position]?.values == mesh.positions.flatMap { [$0.x, $0.y, $0.z] })
#expect(primitive.attributes[.normal]?.values == mesh.normals.flatMap { [$0.x, $0.y, $0.z] })
#expect(primitive.indices == mesh.indices)
}

@Test("Models without geometry, with bad parents or bad materials are rejected")
func invalidModels() {
let material = ProceduralMaterial(name: "Default")
Expand Down
Loading