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
15 changes: 6 additions & 9 deletions .github/workflows/swift-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ defaults:
jobs:
Windows:
name: Windows with SwiftPM
# Swift 6.2.3 ships Clang 19, while the Visual Studio 2026 STL on
# windows-latest requires Clang 20 or newer.
runs-on: windows-2022
env:
ADAENGINE_HEADLESS: "1"
Expand All @@ -24,30 +22,29 @@ jobs:
- name: Setup
uses: compnerd/gha-setup-swift@v0.2.3
with:
branch: swift-6.2.3-release
tag: 6.2.3-RELEASE
branch: swift-6.3.1-release
tag: 6.3.1-RELEASE
- name: Checkout
uses: actions/checkout@v4

- name: Swift Version
run: swift -version

- name: Test
# Swift Testing 6.2.3 on Windows becomes unstable when all tests are
# scheduled at once. Keep bounded parallelism so the suite still
# makes progress without exhausting the runner.
# Rendering and application tests share process-wide engine state.
# Run suites serially so their frame updates cannot race on Windows.
# Build the test product directly: SwiftPM otherwise compiles the
# WASI-only JavaScriptKit command plugin, which cannot build on Windows.
run: |
swift build -j 2 --product AdaEnginePackageTests
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
swift build -j 2 --product TextureAtlasBuilderTool
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
swift test --skip-build --parallel --num-workers 2
swift test --skip-build --no-parallel

- name: Test Mesh Terrain authoring kernel
# Authoring is optional for compiled players; explicitly exercise it in CI.
run: |
swift build -j 2 --traits MeshTerrainAuthoring --product AdaEnginePackageTests
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
swift test --traits MeshTerrainAuthoring --skip-build --parallel --num-workers 2 --filter "MeshTerrain|TriangleBoolean|TriangleIndexEdges"
swift test --traits MeshTerrainAuthoring --skip-build --no-parallel --filter "MeshTerrain|TriangleBoolean|TriangleIndexEdges"
1 change: 1 addition & 0 deletions Editor/Sources/AdaEditor/Assets/Localization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2934,6 +2934,7 @@
"Saving terrain stroke…": "Saving terrain stroke…",
"Release to apply the terrain stroke.": "Release to apply the terrain stroke.",
"Terrain stroke could not be saved. Try again.": "Terrain stroke could not be saved. Try again.",
"Terrain material palette has no room for automatic layers or rules.": "Terrain material palette has no room for automatic layers or rules.",
"Sculpt": "Sculpt",
"Mesh Instancing": "Mesh Instancing",
"Height Generator": "Height Generator",
Expand Down
1 change: 1 addition & 0 deletions Editor/Sources/AdaEditor/Assets/Localization/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2934,6 +2934,7 @@
"Saving terrain stroke…": "Сохраняем поверхность…",
"Release to apply the terrain stroke.": "Отпустите кнопку, чтобы применить мазок.",
"Terrain stroke could not be saved. Try again.": "Не удалось сохранить мазок. Попробуйте ещё раз.",
"Terrain material palette has no room for automatic layers or rules.": "В палитре рельефа нет места для автоматических слоёв или правил.",
"Sculpt": "Рельеф",
"Mesh Instancing": "Инстансинг мешей",
"Height Generator": "Генератор высот",
Expand Down
57 changes: 57 additions & 0 deletions Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
@_spi(AdaEngine) import AdaEngine

/// Adds automatic roles without renumbering or removing imported layers.
enum EditorTerrainAutoPalette {
static func make(existing: TerrainMaterialPalette?, variation: TerrainTextureVariation, addRules: Bool) -> TerrainMaterialPalette? {
var layers = (existing?.layers ?? []).map { layer in
TerrainLayerReference(
id: layer.id,
name: layer.name,
albedoPath: layer.albedoPath,
normalPath: layer.normalPath,
ormPath: layer.ormPath,
blendHeightPath: layer.blendHeightPath,
scale: layer.scale,
tint: layer.tint,
roughness: layer.roughness,
metallic: layer.metallic,
projection: layer.projection,
variation: variation
)
}
guard addRules || existing == nil else {
return .init(
layers: layers,
fallbackLayerID: existing?.fallbackLayerID ?? 0,
interiorLayerID: existing?.interiorLayerID,
rules: existing?.rules ?? []
)
}
func role(_ name: String, tint: Vector4) -> UInt16? {
if let layer = layers.first(where: { $0.name == name }) {
return layer.id
}
let used = Set(layers.map(\.id))
guard layers.count < 32, let id = (UInt16(0)...UInt16(31)).first(where: { !used.contains($0) }) else {
return nil
}
layers.append(.init(id: id, name: name, scale: 4, tint: tint, projection: .triplanar, variation: variation))
return id
}
guard let grass = role("Grass", tint: [0.22, 0.42, 0.13, 1]),
let rock = role("Rock", tint: [0.45, 0.43, 0.4, 1]),
let snow = role("Snow", tint: [0.92, 0.95, 1, 1]) else {
return nil
}
let automatic = Set([grass, rock, snow])
let rules = (existing?.rules ?? []).filter { !automatic.contains($0.layerID) } + [
TerrainMaterialRule(layerID: grass, height: .init(minimum: -10_000, maximum: 8, transition: 4), slope: .init(minimum: 0, maximum: 35, transition: 15)),
.init(layerID: rock, slope: .init(minimum: 35, maximum: 90, transition: 15)),
.init(layerID: snow, height: .init(minimum: 12, maximum: 10_000, transition: 4)),
]
guard rules.count <= 128 else {
return nil
}
return .init(layers: layers, fallbackLayerID: grass, interiorLayerID: rock, rules: rules)
}
}
69 changes: 69 additions & 0 deletions Editor/Sources/AdaEditor/EditorTerrainNoise.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
@_spi(AdaEngine) import AdaEngine
import Foundation

struct EditorTerrainNoise: Sendable {
var seed: UInt64 = 42
var amplitude: Float = 12
var frequency: Float = 0.035

func height(x: Float, z: Float) -> Float {
guard x.isFinite, z.isFinite, frequency.isFinite else {
return 0
}
var result: Float = 0, weight: Float = 1, total: Float = 0
var scale = Double(frequency)
for octave in 0..<5 {
result += value(Double(x) * scale, Double(z) * scale, seed: seed &+ UInt64(octave) &* 7919) * weight
total += weight
weight *= 0.5
scale *= 2
}
return result / total * amplitude
}

private func value(_ x: Double, _ z: Double, seed: UInt64) -> Float {
let ix = floor(x), iz = floor(z)
var u = Float(x - ix), v = Float(z - iz)
u = u * u * (3 - 2 * u); v = v * v * (3 - 2 * v)
func coordinateBits(_ coordinate: Double) -> UInt64 {
Int64(exactly: coordinate).map(UInt64.init(bitPattern:)) ?? coordinate.bitPattern
}
func hash(_ x: Double, _ z: Double) -> Float {
var h = coordinateBits(x) &* 0x9e3779b97f4a7c15 ^ coordinateBits(z) &* 0xbf58476d1ce4e5b9 ^ seed
h = (h ^ (h >> 30)) &* 0xbf58476d1ce4e5b9
h = (h ^ (h >> 27)) &* 0x94d049bb133111eb
h ^= h >> 31
return Float(h & 0xffffff) / Float(0xffffff) * 2 - 1
}
let a = hash(ix, iz), b = hash(ix + 1, iz), c = hash(ix, iz + 1), d = hash(ix + 1, iz + 1)
return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v
}

@concurrent
static func generate(_ settings: Self, at file: URL, manifest: TerrainManifestAsset) async throws {
let cache = try await TerrainRegionCache.open(manifestAt: file.path)
var regions: [TerrainRegionAsset] = []
for reference in manifest.heightRegions {
try Task.checkCancellation()
if let region = try await cache.loadRegion(at: .init(x: Int(reference.x), z: Int(reference.z))) { regions.append(region) }
}
let storage = try TerrainHeightStorage(regions: regions, spacing: manifest.spacing, origin: [manifest.originX, manifest.originZ])
guard storage.tileCoordinates.count <= 240 else { throw EditorTerrainResourceWriter.Failure.modelTooLarge }
for coordinate in storage.tileCoordinates {
guard let snapshot = storage.snapshot(of: coordinate) else { continue }
for z in 0...snapshot.cellCountZ {
try Task.checkCancellation()
for x in 0...snapshot.cellCountX {
let point = TerrainGridPoint(x: coordinate.x * 64 + x, z: coordinate.z * 64 + z)
let height = settings.height(x: manifest.originX + Float(point.x) * manifest.spacing, z: manifest.originZ + Float(point.z) * manifest.spacing)
_ = try storage.setHeight(height, at: point)
}
}
}
regions = []
for coordinate in storage.resourceCoordinates {
if let region = try storage.regionAsset(at: coordinate) { regions.append(region) }
}
_ = try await AssetsManager.saveTerrainRegions(regions, manifest: manifest, at: file.path, mode: .update(removing: []))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class EditorViewportPresentation {
struct Annotations {
let generation: UInt64
let revision: UInt
let brushRevision: UInt64
let size: Size
let theme: Theme
let grid: UIGraphicsContext
Expand All @@ -48,8 +49,12 @@ final class EditorViewportPresentation {
statusRevision &+= 1
}
let state = viewportModel.smoothPerspectiveBlend < 1
? RulerState(center: viewportModel.twoDCenter, zoom: viewportModel.twoDZoom,
blend: viewportModel.smoothPerspectiveBlend, size: viewportModel.viewportSize)
? RulerState(
center: viewportModel.twoDCenter,
zoom: viewportModel.twoDZoom,
blend: viewportModel.smoothPerspectiveBlend,
size: viewportModel.viewportSize
)
: nil
if rulerState != state {
rulerState = state
Expand All @@ -66,8 +71,10 @@ final class EditorViewportPresentation {
// logical size and stretch with that texture until the new target arrives.
let cameraSize = viewportModel.cameraEntity()?.components[Camera.self]?.logicalViewport.rect.size
let size = cameraSize.flatMap { $0.width > 0 && $0.height > 0 ? $0 : nil } ?? viewportModel.viewportSize
let brushRevision = viewportModel.terrainEditing.brushRevision
let annotations: Annotations
if let preparedAnnotations, preparedAnnotations.revision == revision,
preparedAnnotations.brushRevision == brushRevision,
preparedAnnotations.size == size, preparedAnnotations.theme == theme {
annotations = preparedAnnotations
} else {
Expand All @@ -81,13 +88,22 @@ final class EditorViewportPresentation {
let orientation = viewportModel.displayMode == .threeD
? CompassOrientation(right: viewportModel.right3D, up: viewportModel.up3D, front: viewportModel.front3D) : nil
annotations = Annotations(
generation: annotationGeneration, revision: revision, size: size, theme: theme, grid: grid, gizmos: gizmos,
interactionGizmo: viewportModel.captureGizmo(gizmo), compassOrientation: orientation
generation: annotationGeneration,
revision: revision,
brushRevision: brushRevision,
size: size,
theme: theme,
grid: grid,
gizmos: gizmos,
interactionGizmo: viewportModel.captureGizmo(gizmo),
compassOrientation: orientation
)
preparedAnnotations = annotations
}
return { [weak self, weak viewportModel] in
guard let self else { return }
guard let self else {
return
}
self.presentedAnnotations = annotations
viewportModel?.presentedGizmo = annotations.interactionGizmo
if self.compassOrientation != annotations.compassOrientation {
Expand Down Expand Up @@ -136,9 +152,6 @@ struct EditorViewportRulerLayer: View {
var body: some View {
let revision = presentation.rulerRevision
return GeometryReader { proxy in
// A declaration is required inside AdaUI's result builder.
// swiftlint:disable:next redundant_discardable_let
let _ = revision
let ruler = viewportModel.coordinateRuler(in: proxy.size)
ZStack(anchor: .topLeading) {
RectangleShape()
Expand All @@ -162,6 +175,7 @@ struct EditorViewportRulerLayer: View {
.offset(x: 2, y: max(20, proxy.size.height - 20))
}
}
.id(revision)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ extension EditorSceneViewportModel {
join: { [weak self] in self?.joinSelectedMeshTerrain() },
detach: { [weak self] in self?.detachSelectedMeshTerrain() },
cancel: { [weak self] in
guard let self else { return }
guard let self else {
return
}
if !self.cancelActiveMeshTerrainTransform() { self.endTerrainStroke(cancelled: true) }
}
)
Expand Down Expand Up @@ -125,12 +127,21 @@ extension EditorSceneViewportModel {

private func configureTerrainMaterials(addRules: Bool = true) {
let variation = TerrainTextureVariation(isEnabled: terrainEditing.usesTextureVariation, seed: terrainEditing.noise.seed)
let palette = EditorTerrainAutoPalette.make(existing: terrainEditing.sourceManifest?.materials, variation: variation, addRules: addRules)
guard let palette = EditorTerrainAutoPalette.make(
existing: terrainEditing.sourceManifest?.materials,
variation: variation,
addRules: addRules
) else {
terrainEditing.sourceStatus = "Terrain material palette has no room for automatic layers or rules."
return
}
performTerrainResourceCommand(.materials(palette))
}

private func registerTerrainMesh() {
guard !terrainEditing.isSourceBusy else { return }
guard !terrainEditing.isSourceBusy else {
return
}
if let file = terrainProjectFile(terrainEditing.meshReference) {
performTerrainResourceCommand(.mesh(file))
return
Expand All @@ -147,7 +158,9 @@ extension EditorSceneViewportModel {
}

private func registerTerrainTexture() {
guard !terrainEditing.isSourceBusy else { return }
guard !terrainEditing.isSourceBusy else {
return
}
let used = Set(terrainEditing.sourceManifest?.materials?.layers.map(\.id) ?? [])
guard let id = (UInt16(0)...UInt16(31)).first(where: { !used.contains($0) }) else {
return
Expand Down Expand Up @@ -182,7 +195,9 @@ extension EditorSceneViewportModel {
}

private func rejectHeightfieldTool() -> Bool {
guard isMeshTerrainSource(for: selectedEditorID) else { return false }
guard isMeshTerrainSource(for: selectedEditorID) else {
return false
}
terrainEditing.sourceFailure = nil
terrainEditing.sourceStatus = "Choose a Heightfield for this tool."
return true
Expand Down Expand Up @@ -237,8 +252,11 @@ extension EditorSceneViewportModel {
var meshReceipt: EditorMeshTerrainCreation.Receipt?
do {
let file = context.assetsRoot.appendingPathComponent("terrain-\(UUID().uuidString.lowercased()).aterrain")
if backend == .mesh { meshReceipt = try await EditorMeshTerrainCreation.create(at: file, noise: settings) }
else { try await Self.writeNewTerrain(at: file, noise: settings) }
if backend == .mesh {
meshReceipt = try await EditorMeshTerrainCreation.create(at: file, noise: settings)
} else {
try await Self.writeNewTerrain(at: file, noise: settings)
}
guard !Task.isCancelled, self.terrainEditing.sourceGeneration == generation, self.sceneModel == baseline else {
if let meshReceipt { await EditorMeshTerrainCreation.discard(meshReceipt) }
return
Expand Down
Loading
Loading