From 83c66049fb48acf4c2941322e80ce9dcec7607a7 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 01:46:29 +0300 Subject: [PATCH 01/11] 3D: fog and light shafts, tiled local lights, shadow face cache, glTF material extensions Stage 5 (atmosphere) - Fog3D in Environment3D: distance and exponential height fog in linear HDR before tone mapping, with sky influence and sun in-scattering. - Sun light shafts: half-resolution raymarch through the fog that samples the cascaded shadow map, composited with a depth-aware upsample. Stage 6 (lighting scale) - Tiled local lights: the CPU bins up to 32 local lights into screen tiles with a 32-bit mask each, and the main pass loops only over the lights in its tile. - Local shadow face cache: unchanged shadow faces keep their atlas tile; a scene digest skips rebuilding per-face lists while casters and light matrices are unchanged. Stage 2 (materials) - KHR_texture_transform, KHR_materials_ior and KHR_materials_specular (factors); the dielectric reflectance replaces the fixed 0.04. - Extension policy: NativeGLTFLoader.supportedExtensions, import warnings for optional extensions that are not implemented, ModelAsset3D.importWarnings. Adds Metal golden frames for fog and light shafts, benchmark configurations, and records measurements and limits in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 147 ++++++++++- Sources/AdaAssets/GLTF/GLTFLoader.swift | 6 +- Sources/AdaAssets/GLTF/GLTFMaterialData.swift | 32 ++- .../GLTF/NativeGLTFLoader+Materials.swift | 154 +++++++---- .../GLTF/NativeGLTFLoader+Schema.swift | 23 ++ Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 31 ++- .../Assets/Shaders/directional_shadow_3d.glsl | 3 + .../Assets/Shaders/flat3d.glsl | 64 ++++- .../Assets/Shaders/motion_3d.glsl | 8 +- .../Shaders/screen_space_reflection.glsl | 66 ++++- .../Assets/Shaders/shadow_clear_3d.glsl | 15 ++ .../Assets/Shaders/volumetric_fog_3d.glsl | 96 +++++++ .../CorePipelines/Core3DPlugin.swift | 6 + .../CorePipelines/Environment3D.swift | 16 +- .../CorePipelines/Fog3D.swift | 160 ++++++++++++ .../CorePipelines/LightTiles3D.swift | 140 ++++++++++ .../CorePipelines/LocalLighting3D.swift | 25 +- .../CorePipelines/PBR3DUniforms.swift | 76 +++++- .../Render3DLightStatistics.swift | 5 +- .../LocalShadow3DRenderNode.swift | 167 ++++++++---- .../RenderNodes3D/Main3DRenderNode.swift | 29 +++ .../ScreenSpaceReflectionRenderNode.swift | 20 +- .../VolumetricFog3DRenderNode.swift | 185 ++++++++++++++ .../ScreenSpaceReflectionPipeline.swift | 5 + .../CorePipelines/ShadowSceneDigest3D.swift | 35 +++ .../CorePipelines/VisibleMesh3DLists.swift | 57 +++++ Sources/AdaRender/Materials/PBRMaterial.swift | 24 ++ .../AdaScene/3D/ModelAsset3D+Materials.swift | 6 + Sources/AdaScene/3D/ModelAsset3D.swift | 4 + .../GLTFMaterialExtensionTests.swift | 112 ++++++++ .../GLTFMaterialExtensionMappingTests.swift | 35 +++ .../GoldenFrames/metal/pbr-spheres-fog.png | Bin 0 -> 82180 bytes .../metal/pbr-spheres-light-shafts.png | Bin 0 -> 104212 bytes Tests/AdaEngineTests/Render3DFogTests.swift | 240 ++++++++++++++++++ .../Render3DLightTilesTests.swift | 120 +++++++++ .../Render3DMaterialExtensionTests.swift | 177 +++++++++++++ .../Render3DPerformanceBenchmarkTests.swift | 30 ++- .../Render3DShadowFaceCacheTests.swift | 158 ++++++++++++ 38 files changed, 2336 insertions(+), 141 deletions(-) create mode 100644 Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.glsl create mode 100644 Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.glsl create mode 100644 Sources/AdaCorePipelines/CorePipelines/Fog3D.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/LightTiles3D.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift create mode 100644 Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift create mode 100644 Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift create mode 100644 Tests/AdaEngineTests/GoldenFrames/metal/pbr-spheres-fog.png create mode 100644 Tests/AdaEngineTests/GoldenFrames/metal/pbr-spheres-light-shafts.png create mode 100644 Tests/AdaEngineTests/Render3DFogTests.swift create mode 100644 Tests/AdaEngineTests/Render3DLightTilesTests.swift create mode 100644 Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift create mode 100644 Tests/AdaEngineTests/Render3DShadowFaceCacheTests.swift diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 4fff33940..61c52e408 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -294,17 +294,20 @@ Each item needs its own short requirements note before implementation. - [x] Stage 1: depth of field. - [ ] Stage 1: motion blur (needs a velocity buffer that exists without temporal upscaling). - [ ] Stage 1: quality tiers for depth of field, bloom and auto-exposure. -- [ ] Stage 2: `KHR_texture_transform`, IOR, specular and clearcoat material extensions. +- [x] Stage 2: `KHR_texture_transform`, `KHR_materials_ior` and `KHR_materials_specular` factors (see "Stage 2, first slice"). +- [ ] Stage 2: clearcoat material extension. - [ ] Stage 2: sheen, anisotropy and `KHR_lights_punctual` import. -- [ ] Stage 2: unsupported-extension degradation policy and documented supported list. +- [x] Stage 2: unsupported-extension degradation policy and documented supported list (`NativeGLTFLoader.supportedExtensions`, `GLTFImportResult.warnings`). - [ ] Stage 3: morph-target asset, glTF import and GPU evaluation. - [ ] Stage 3: morph with skinning, shadows, motion vectors, bounds and animation weights. - [ ] Stage 4: OIT mode with sorted fallback. - [ ] Stage 4: transmission/volume pass. -- [ ] Stage 5: distance/height fog. +- [x] Stage 5: distance/height fog (`Fog3D` in `Environment3D`, applied in the composite pass; see "Stage 5, first slice"). - [ ] Stage 5: physically based sky feeding IBL and time-of-day driver. -- [ ] Stage 5: volumetric fog and planar reflections. -- [ ] Stage 6: clustered or tiled light assignment with measured limits. +- [x] Stage 5: sun light shafts through the fog (half-resolution raymarch, `Fog3D.volumetric`; see "Stage 5, second slice"). +- [ ] Stage 5: froxel volumetric fog for local lights, fog volumes and planar reflections. +- [x] Stage 6: tiled local-light assignment with measured limits (2D tiles, CPU masks; depth slicing is not done; see "Stage 6, first slice"). +- [x] Stage 6: reuse of unchanged local shadow faces (see "Stage 6, second slice"). - [ ] Stage 6: contact shadows, soft shadows, area lights and cookies. - [ ] Stage 7: decals, static-mesh LOD, terrain, water, foliage, 3D particles (each with its own requirements note). - [ ] Stage 7: Hi-Z/indirect-draw parity on WebGPU and Vulkan or documented fallback. @@ -628,6 +631,140 @@ press" test, and `Physics3DTests.fixedSchedulerAdvancesAndWritesBackDynamicBody` fails under parallel load but passes alone. Not verified: Apple mobile, Windows (the GPU suites are macOS-only), the windowed presentation notification path. +### Stage 5, first slice: distance and height fog (2026-10-18) + +`Environment3D.fog` (`Fog3D`) adds exponential height fog with distance start, maximum opacity, optional sky +influence and sun in-scattering. It is evaluated in `screen_space_reflection.glsl`, in linear HDR on the lit +color and before exposure, bloom add, grading and tone mapping, so it follows exposure like any other light. +Optical depth is the analytic integral of `density * exp(-heightFalloff * (y - baseHeight))` along the view +ray (uniform distance fog when `heightFalloff` is `0`). It costs one extra branch per pixel when off and +allocates nothing. Sky pixels are untouched unless `skyInfluence` is above zero. Sun in-scattering reads the +primary directional light's radiance and direction. SSR-traced reflection hits are not fogged separately +(the fog applies to the final composited pixel). Tests: `Render3DFogSettingsTests` and +`Render3DGPUTests/Fog` (Metal passes, with the `pbr-spheres-fog` golden frame recorded; WebGPU references are not yet recorded because they need `script/validate_webgpu.sh` with Swan/Dawn and Swift 6.3.2). + +### Stage 5, second slice: volumetric light shafts (2026-10-18) + +The ADR first named froxel volumetrics. This slice uses the approach Bevy ships (a full-screen raymarch that +samples shadow maps) instead of Godot's compute froxel grid: it needs no compute path, so it works on every +backend the main pass does, and it reuses the cascaded sun shadows already rendered. + +`Fog3D.volumetric` (`VolumetricFog3D`: intensity, anisotropy, steps, maximum distance) enables +`VolumetricFog3DRenderNode`, which runs after `Main3D` and before the composite. Per half-resolution pixel it +marches the view ray to the surface (or to `maxDistance` for sky pixels) with interleaved-gradient jitter, +integrates the fog medium (the same density and height falloff as the analytic fog), looks up the covering +sun cascade and accumulates `density * transmittance * visibility`, scaled by a Henyey-Greenstein phase and the +sun radiance. The composite adds the bilinearly upsampled result in linear HDR, and the unshadowed analytic +`sunInscattering` term switches off while shafts run. A camera without active shafts allocates no texture +(`VolumetricFog3DViews`) and records no pass. + +The upsample weights the four surrounding half-resolution texels by bilinear distance and by how close their +view depth is to the full-resolution pixel's (sky counts as very far), so shafts do not bleed across silhouettes. + +**Cost (Apple M-class GPU, 1080p, release, 400-sphere benchmark scene, best of 3 rounds).** Analytic fog is free +(`free` 2.43 ms bare, 2.45 ms with fog). Light shafts add 0.26 ms GPU for the pass at 16 steps (`free` 2.69 ms) +and 0.65 ms at 48 steps (`free` 3.09 ms). Reproduce with `ADAENGINE_PERF_FILTER='bare,fog,shafts'`. + +Limits of this slice: sun only (no point or spot lights, no fog volumes), one shadow tap per step, no temporal +accumulation (the jitter shows as fine noise), and no WebGPU validation yet. Tests: `Render3DGPUTests/Fog` (additive light, shadows carving the shafts, texture +release, `pbr-spheres-light-shafts` golden frame on Metal). + +### Stage 6, first slice: tiled local lights (2026-10-18) + +Measured before building: on the benchmark scene the per-pixel loop over 8 local lights cost 0.47 ms (geometry +1.71 ms against 1.24 ms without local lights) and over 32 lights 1.33 ms (2.57 ms). The local shadow pass is +a separate 1.10 ms for 13 faces, which this slice does not touch. + +The CPU now bins the (up to 32) local lights into screen tiles of at least 32 pixels (the size doubles until +there are at most 4,096 tiles, so 1080p is 60 by 34 tiles and 4K is 2,040 to 8,160 at 64 pixels). +Each tile holds a 32-bit light mask; a light sets the bits of the tiles its view-space bounding box projects +onto, plus one pixel of margin. A light whose sphere reaches the camera plane marks every tile. The main pass +projects the fragment's view-space position with the same matrix to find its tile (so the lookup does not depend +on a backend's fragment-coordinate origin), then loops only over the set bits. The masks are conservative: a +light outside its range already contributed zero, so the lit image is unchanged. `Environment3D.tiledLocalLights` +(on by default) falls back to the flat loop, and foveated rendering keeps the flat loop. + +**Results (same machine and method as Performance pass 1).** With 32 local lights geometry went from 2.57 ms to +2.09 ms and `free` from 3.30 ms to 2.81 ms. With 8 lights the change is within noise (1.71 ms to 1.69 ms), +so this helps scenes with many lights, not typical ones. The remaining 0.83 ms at 32 lights is mostly real +shading: the benchmark lights overlap heavily (range 5 at 4 unit spacing), so each pixel still evaluates several. + +Not done: depth slicing into clusters (a tile still lists lights that are in front of or behind the surface), +tighter bounds for spot cones, and a limit above 32 local lights. Those only pay off with many overlapping +lights at different depths. Tests: `Render3DLightTilesMathTests` (bounds, near-plane handling, viewport offset, +32-light cap), `Render3DGPUTests/LightTiles` (tiled and flat frames match; the test fails when the mask is +broken) and the `32 lights` benchmark configuration. + +### Stage 6, second slice: reusing unchanged local shadow faces (2026-10-19) + +Every frame the local shadow pass cleared the atlas and redrew all faces (13 faces, 1.10 ms in the benchmark scene) +even when nothing near a light had moved. Now each face keeps what it drew: + +- `VisibleMesh3DLists` fingerprints every shadow pass it prepares (`shadowDigest`): the batch key of each batch + (mesh part buffers, material parameter buffer, base color texture, skin), each caster's transform, color factor and + fade. A pass with a skinned caster has no fingerprint, because a pose changes without any visible change on + the CPU, so those faces are always redrawn. +- `LocalShadow3DRenderNode` combines it with the face's view-projection matrix (the light's position, direction, range and + cone) and compares it with the one stored for the same atlas tile. If every face matches, the pass is skipped + entirely. Otherwise the atlas is loaded rather than cleared, each dirty tile is reset with a small + scissored clear draw and then redrawn, and clean tiles are left alone. A new atlas or a different budget starts + from scratch. `LocalShadowSettings3D.reusesStaticFaces` (on by default) turns it off, and + `Render3DLightStatistics.Snapshot.reusedShadowFaces` reports how many faces the last frame kept. + +**Result (same machine and method).** On the static benchmark scene the local shadow pass goes from 1.10 ms to +0 ms and `free` from 2.39 ms to 1.90 ms. Preparing the per-face visible lists still runs on the CPU every frame +because the fingerprint comes from it. A moving light dirties all of its faces; a moving caster dirties only the +faces whose frustum it enters or leaves. + +**CPU follow-up.** Fingerprinting a face needed its visible list, so the node still prepared every face each frame. +`ShadowSceneDigest3D` now fingerprints the shadow casters once per frame (transform, mesh part buffers, material +identity and revision, base color texture). While that digest and a face's matrix are both unchanged, the face +is kept without building its list; when the digest changes, every face is prepared and compared by content as +before, so a caster that moves still dirties only the faces it touches. A scene with skinned casters, or with +level-of-detail alternatives while LOD is on (the chosen mesh follows the camera), has no digest and keeps the +per-face path. Measured on 4,900 meshes the node's CPU time per frame went from 0.49 ms to 0.21 ms; the frame +itself is GPU-bound there (`free` 7.4 to 7.5 ms either way), so the saving shows on CPU-bound machines and +scenes, not in the benchmark's frame time. The digest is now the main remaining cost (about 40 ns per caster); an +extraction-time change counter would remove it. + +Limits: meshes edited in place (same buffers, new vertices) are not noticed; a caster that animates only through +skinning keeps its faces dirty every frame; the directional cascades (0.53 ms) are not cached, since they follow +the camera. Tests: `Render3DShadowFaceCacheSettingsTests`, and `Render3DGPUTests/ShadowFaceCache` (a static scene +keeps all faces and looks identical to a redraw, a moved caster dirties some but not all faces and the partly +reused atlas equals a full redraw, a moved light dirties every face). + +### Stage 2, first slice: texture transforms, IOR, specular and the extension policy (2026-10-19) + +**Correction to G2.** The audit said the loader "rejects" extensions it does not know. It only rejects those a file +lists in `extensionsRequired`, which is what the glTF specification asks for; extensions listed only in +`extensionsUsed` were already ignored, silently. The gap was the silence, not a rejection. + +- **Policy.** `NativeGLTFLoader.supportedExtensions` is the public list (`KHR_mesh_quantization`, + `KHR_materials_emissive_strength`, `KHR_texture_transform`, `KHR_materials_ior`, `KHR_materials_specular`). A file that + *requires* anything else still fails with `unsupportedRequiredExtension`. An extension a file merely *uses* is + ignored and reported in `GLTFImportResult.warnings`, as is a `KHR_materials_specular` texture (only the factors are + read). `ModelAsset3D.importWarnings` carries them to the engine; nothing is logged yet because `AdaScene` has no + logging dependency. +- **`KHR_texture_transform`** for base color, metallic/roughness, normal, occlusion and emission: offset, rotation, scale and + the `texCoord` override. `PBRMaterial.textureTransforms` holds five `PBRTextureTransform`s; the material uniform stores each + as a 2x2 matrix and an offset, and `flat3d.glsl` applies it in `materialUV`. The directional-shadow and motion shaders + apply the base-color transform to their alpha test, so cut-out shadows and motion vectors follow the texture. A normal map + whose transform rotates or flips axes stops using authored tangents (the derivative frame follows the transformed UVs). +- **`KHR_materials_ior` and `KHR_materials_specular` (factors).** Non-metal reflectance at normal incidence is + `min(((ior - 1) / (ior + 1))^2 * specularColor, 1) * specularFactor`, computed on the CPU into `PBR3DUniform.dielectric` + and used in place of the fixed 0.04 in the direct, local-light and image-based terms. The defaults give 0.04, so + existing frames are unchanged (all golden frames still match). The glTF specular `F90` term is not modeled (full + white at grazing angles, as before), and the SSR composite keeps its own fixed reflectivity. +- **Validation.** Malformed transforms (wrong vector length, UV set above 1) and out-of-range reflectance (IOR outside + 1...100, negative or short specular values) fail with `invalidMaterial`. + +Cost: the per-material uniform grew from 80 to 272 bytes and the deduplication key from five to sixteen vectors; the benchmark +frame (`bare`, 1.91 ms) is unchanged. Tests: `GLTFMaterialExtensionTests` (import, validation, warnings, the required-extension +failure), `GLTFMaterialExtensionMappingTests` (importer to `PBRMaterial`), `Render3DMaterialExtensionConstantTests` and +`Render3DGPUTests/MaterialExtensions` (a transform moves, scales and rotates the texture, a full turn is the identity, higher IOR +brightens and zero specular dims the highlight, a red tint shifts it, defaults change nothing). Not validated on WebGPU (no +Swan/Dawn here). Still open in Stage 2: clearcoat, sheen, anisotropy, specular textures and `KHR_lights_punctual`. + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Sources/AdaAssets/GLTF/GLTFLoader.swift b/Sources/AdaAssets/GLTF/GLTFLoader.swift index bd7a5b6a6..18abc26b9 100644 --- a/Sources/AdaAssets/GLTF/GLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/GLTFLoader.swift @@ -159,6 +159,8 @@ public struct GLTFImportResult: Sendable { public let defaultScene: Int? public let skins: [Skin] public let animations: [Animation] + /// Content the importer read but could not honor, such as an optional extension it does not implement. + public let warnings: [String] public init( nodes: [Node], @@ -170,7 +172,8 @@ public struct GLTFImportResult: Sendable { defaultScene: Int?, skins: [Skin] = [], animations: [Animation] = [], - samplers: [Sampler] = [] + samplers: [Sampler] = [], + warnings: [String] = [] ) { self.nodes = nodes self.meshes = meshes @@ -182,6 +185,7 @@ public struct GLTFImportResult: Sendable { self.skins = skins self.animations = animations self.samplers = samplers + self.warnings = warnings } } diff --git a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift index 42547f57a..3c6a7acac 100644 --- a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift +++ b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift @@ -7,6 +7,21 @@ public extension GLTFImportResult { case blend = "BLEND" } + /// `KHR_texture_transform`: the UV of a texture is scaled, then rotated (counter-clockwise, radians), then offset. + struct TextureTransform: Sendable, Equatable { + public var offset: Vector2 + public var rotation: Float + public var scale: Vector2 + + public static let identity = TextureTransform() + + public init(offset: Vector2 = .zero, rotation: Float = 0, scale: Vector2 = .one) { + self.offset = offset + self.rotation = rotation + self.scale = scale + } + } + /// glTF metallic/roughness material values; color factors are linear. struct Material: Sendable { public let name: String? @@ -27,6 +42,13 @@ public extension GLTFImportResult { public let doubleSided: Bool /// UV set indices for base color, metallic/roughness, normal, occlusion, emission. public let textureCoordinates: [Int] + /// `KHR_texture_transform` for base color, metallic/roughness, normal, occlusion, emission. + public let textureTransforms: [TextureTransform] + /// `KHR_materials_ior`. The glTF default is 1.5, which gives the 4% dielectric reflectance. + public let ior: Float + /// `KHR_materials_specular` strength and tint of the dielectric reflection. Specular textures are not read. + public let specularFactor: Float + public let specularColorFactor: Vector3 public init( name: String?, @@ -45,7 +67,11 @@ public extension GLTFImportResult { alphaMode: AlphaMode = .opaque, alphaCutoff: Float = 0.5, doubleSided: Bool = false, - textureCoordinates: [Int] = [0, 0, 0, 0, 0] + textureCoordinates: [Int] = [0, 0, 0, 0, 0], + textureTransforms: [TextureTransform] = Array(repeating: .identity, count: 5), + ior: Float = 1.5, + specularFactor: Float = 1, + specularColorFactor: Vector3 = .one ) { self.name = name self.baseColorFactor = baseColorFactor @@ -64,6 +90,10 @@ public extension GLTFImportResult { self.alphaCutoff = alphaCutoff self.doubleSided = doubleSided self.textureCoordinates = textureCoordinates + self.textureTransforms = textureTransforms + self.ior = ior + self.specularFactor = specularFactor + self.specularColorFactor = specularColorFactor } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift index 1f9797beb..aba7cc486 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift @@ -20,56 +20,110 @@ extension NativeGLTFLoader { } func importMaterials(_ gltf: GLTF) throws -> [GLTFImportResult.Material] { - try (gltf.materials ?? []).enumerated().map { index, material in - let pbr = material.pbrMetallicRoughness - let base = pbr?.baseColorFactor ?? [1, 1, 1, 1] - let emissive = material.emissiveFactor ?? [0, 0, 0] - let metallic = pbr?.metallicFactor ?? 1 - let roughness = pbr?.roughnessFactor ?? 1 - let normalScale = material.normalTexture?.scale ?? 1 - let occlusion = material.occlusionTexture?.strength ?? 1 - let cutoff = material.alphaCutoff ?? 0.5 - let strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1 - let coordinates = [ - pbr?.baseColorTexture?.texCoord ?? 0, - pbr?.metallicRoughnessTexture?.texCoord ?? 0, - material.normalTexture?.texCoord ?? 0, - material.occlusionTexture?.texCoord ?? 0, - material.emissiveTexture?.texCoord ?? 0, - ] - let textures = [ - pbr?.baseColorTexture?.index, - pbr?.metallicRoughnessTexture?.index, - material.normalTexture?.index, - material.occlusionTexture?.index, - material.emissiveTexture?.index, - ] - guard base.count == 4, emissive.count == 3, - (base + emissive + [metallic, roughness, occlusion]).allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }), - normalScale.isFinite, cutoff.isFinite, cutoff >= 0, strength.isFinite, strength >= 0, - coordinates.allSatisfy({ $0 == 0 || $0 == 1 }), - textures.compactMap({ $0 }).allSatisfy({ (gltf.textures ?? []).indices.contains($0) }), - let alpha = GLTFImportResult.AlphaMode(rawValue: material.alphaMode ?? "OPAQUE") - else { throw GLTFError.invalidMaterial(index) } - return .init( - name: material.name, - baseColorFactor: Vector4(base[0], base[1], base[2], base[3]), - baseColorTextureIndex: textures[0], - metallicFactor: metallic, - roughnessFactor: roughness, - metallicRoughnessTextureIndex: textures[1], - normalTextureIndex: textures[2], - normalScale: normalScale, - occlusionTextureIndex: textures[3], - occlusionStrength: occlusion, - emissiveTextureIndex: textures[4], - emissiveFactor: Vector3(emissive[0], emissive[1], emissive[2]), - emissiveStrength: strength, - alphaMode: alpha, - alphaCutoff: cutoff, - doubleSided: material.doubleSided ?? false, - textureCoordinates: coordinates - ) + var result: [GLTFImportResult.Material] = [] + for (index, material) in (gltf.materials ?? []).enumerated() { + result.append(try importMaterial(material, index: index, textureCount: (gltf.textures ?? []).count)) + } + return result + } + + /// The `KHR_texture_transform` of every texture slot (base color, metallic/roughness, normal, occlusion, emission). + private func textureTransformInfos(_ material: GLTF.Material) -> [GLTF.TextureExtensions.Transform?] { + let pbr = material.pbrMetallicRoughness + var infos: [GLTF.TextureExtensions.Transform?] = [] + infos.append(pbr?.baseColorTexture?.extensions?.KHR_texture_transform) + infos.append(pbr?.metallicRoughnessTexture?.extensions?.KHR_texture_transform) + infos.append(material.normalTexture?.extensions?.KHR_texture_transform) + infos.append(material.occlusionTexture?.extensions?.KHR_texture_transform) + infos.append(material.emissiveTexture?.extensions?.KHR_texture_transform) + return infos + } + + private func isValid(_ info: GLTF.TextureExtensions.Transform?) -> Bool { + guard let info else { return true } + let offset: [Float] = info.offset ?? [0, 0] + let scale: [Float] = info.scale ?? [1, 1] + let values: [Float] = offset + scale + [info.rotation ?? 0] + return offset.count == 2 && scale.count == 2 && values.allSatisfy { $0.isFinite } + } + + private func importMaterial(_ material: GLTF.Material, index: Int, textureCount: Int) throws -> GLTFImportResult.Material { + let pbr = material.pbrMetallicRoughness + let base: [Float] = pbr?.baseColorFactor ?? [1, 1, 1, 1] + let emissive: [Float] = material.emissiveFactor ?? [0, 0, 0] + let metallic: Float = pbr?.metallicFactor ?? 1 + let roughness: Float = pbr?.roughnessFactor ?? 1 + let normalScale: Float = material.normalTexture?.scale ?? 1 + let occlusion: Float = material.occlusionTexture?.strength ?? 1 + let cutoff: Float = material.alphaCutoff ?? 0.5 + let strength: Float = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1 + let transformInfos = textureTransformInfos(material) + // KHR_texture_transform may name another UV set for its texture. + let authoredCoordinates: [Int] = [ + pbr?.baseColorTexture?.texCoord ?? 0, + pbr?.metallicRoughnessTexture?.texCoord ?? 0, + material.normalTexture?.texCoord ?? 0, + material.occlusionTexture?.texCoord ?? 0, + material.emissiveTexture?.texCoord ?? 0, + ] + var coordinates: [Int] = [] + for slot in authoredCoordinates.indices { + coordinates.append(transformInfos[slot]?.texCoord ?? authoredCoordinates[slot]) + } + let transformsAreValid = transformInfos.allSatisfy { isValid($0) } + var transforms: [GLTFImportResult.TextureTransform] = [] + for info in transformInfos { + guard let info, transformsAreValid else { + transforms.append(.identity) + continue + } + let offset: [Float] = info.offset ?? [0, 0] + let scale: [Float] = info.scale ?? [1, 1] + transforms.append(.init(offset: Vector2(offset[0], offset[1]), rotation: info.rotation ?? 0, scale: Vector2(scale[0], scale[1]))) } + let ior: Float = material.extensions?.KHR_materials_ior?.ior ?? 1.5 + let specular = material.extensions?.KHR_materials_specular + let specularFactor: Float = specular?.specularFactor ?? 1 + let specularColor: [Float] = specular?.specularColorFactor ?? [1, 1, 1] + let textures: [Int?] = [ + pbr?.baseColorTexture?.index, + pbr?.metallicRoughnessTexture?.index, + material.normalTexture?.index, + material.occlusionTexture?.index, + material.emissiveTexture?.index, + ] + let unitValues: [Float] = base + emissive + [metallic, roughness, occlusion] + let texturesExist = textures.compactMap { $0 }.allSatisfy { $0 >= 0 && $0 < textureCount } + let factorsAreValid = base.count == 4 && emissive.count == 3 && unitValues.allSatisfy { $0.isFinite && $0 >= 0 && $0 <= 1 } + let scalarsAreValid = normalScale.isFinite && cutoff.isFinite && cutoff >= 0 && strength.isFinite && strength >= 0 + let specularIsValid = ior.isFinite && ior >= 1 && ior <= 100 && specularFactor.isFinite && specularFactor >= 0 + && specularColor.count == 3 && specularColor.allSatisfy { $0.isFinite && $0 >= 0 } + guard factorsAreValid, scalarsAreValid, specularIsValid, texturesExist, transformsAreValid, + coordinates.allSatisfy({ $0 == 0 || $0 == 1 }), + let alpha = GLTFImportResult.AlphaMode(rawValue: material.alphaMode ?? "OPAQUE") + else { throw GLTFError.invalidMaterial(index) } + return .init( + name: material.name, + baseColorFactor: Vector4(base[0], base[1], base[2], base[3]), + baseColorTextureIndex: textures[0], + metallicFactor: metallic, + roughnessFactor: roughness, + metallicRoughnessTextureIndex: textures[1], + normalTextureIndex: textures[2], + normalScale: normalScale, + occlusionTextureIndex: textures[3], + occlusionStrength: occlusion, + emissiveTextureIndex: textures[4], + emissiveFactor: Vector3(emissive[0], emissive[1], emissive[2]), + emissiveStrength: strength, + alphaMode: alpha, + alphaCutoff: cutoff, + doubleSided: material.doubleSided ?? false, + textureCoordinates: coordinates, + textureTransforms: transforms, + ior: ior, + specularFactor: specularFactor, + specularColorFactor: Vector3(specularColor[0], specularColor[1], specularColor[2]) + ) } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift index 2af814cc6..42edb1932 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift @@ -83,10 +83,21 @@ extension NativeGLTFLoader { let primitives: [Primitive] } + struct TextureExtensions: Codable { + struct Transform: Codable { + let offset: [Float]? + let rotation: Float? + let scale: [Float]? + let texCoord: Int? + } + let KHR_texture_transform: Transform? + } + struct Material: Codable { struct TextureInfo: Codable { let index: Int let texCoord: Int? + let extensions: TextureExtensions? } struct PBR: Codable { @@ -101,17 +112,29 @@ extension NativeGLTFLoader { let index: Int let texCoord: Int? let scale: Float? + let extensions: TextureExtensions? } struct OcclusionTextureInfo: Codable { let index: Int let texCoord: Int? let strength: Float? + let extensions: TextureExtensions? } struct Extensions: Codable { struct EmissiveStrength: Codable { let emissiveStrength: Float } + struct IOR: Codable { let ior: Float? } + struct TextureReference: Codable { let index: Int } + struct Specular: Codable { + let specularFactor: Float? + let specularColorFactor: [Float]? + let specularTexture: TextureReference? + let specularColorTexture: TextureReference? + } let KHR_materials_emissive_strength: EmissiveStrength? + let KHR_materials_ior: IOR? + let KHR_materials_specular: Specular? } let name: String? diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 05ea7a346..23fef83cc 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -17,6 +17,31 @@ public struct NativeGLTFLoader: GLTFLoader { } /// Loads a glTF or GLB document from memory. + /// The glTF extensions this loader implements. A file that *requires* any other extension fails to load; an + /// extension it merely *uses* is ignored and reported in ``GLTFImportResult/warnings``. + public static let supportedExtensions: Set = [ + "KHR_mesh_quantization", + "KHR_materials_emissive_strength", + "KHR_texture_transform", + "KHR_materials_ior", + "KHR_materials_specular", + ] + + /// Optional content the importer cannot honor: unimplemented extensions the file lists as used, and the parts + /// of an implemented extension that are skipped (specular textures). + private func warnings(for gltf: GLTF) -> [String] { + var result: [String] = [] + for name in (gltf.extensionsUsed ?? []).sorted() where !Self.supportedExtensions.contains(name) { + result.append("The extension \(name) is not supported and was ignored.") + } + for (index, material) in (gltf.materials ?? []).enumerated() { + if let specular = material.extensions?.KHR_materials_specular, specular.specularTexture != nil || specular.specularColorTexture != nil { + result.append("Material \(index) uses a KHR_materials_specular texture; only its factors were applied.") + } + } + return result + } + public func load(data: Data, baseURL: URL? = nil) throws -> GLTFImportResult { let resourceBaseURL = baseURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) @@ -32,8 +57,7 @@ public struct NativeGLTFLoader: GLTFLoader { binaryBuffer = nil } - let supportedExtensions: Set = ["KHR_mesh_quantization", "KHR_materials_emissive_strength"] - if let unsupportedExtension = gltf.extensionsRequired?.first(where: { !supportedExtensions.contains($0) }) { + if let unsupportedExtension = gltf.extensionsRequired?.first(where: { !Self.supportedExtensions.contains($0) }) { throw GLTFError.unsupportedRequiredExtension(unsupportedExtension) } @@ -201,7 +225,8 @@ public struct NativeGLTFLoader: GLTFLoader { defaultScene: gltf.scene, skins: skins, animations: animations, - samplers: samplers + samplers: samplers, + warnings: warnings(for: gltf) ) } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl index 2c47ceaa2..0fbe3efa4 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl @@ -65,6 +65,8 @@ layout (binding = 15) uniform PBR3DUniform { vec4 u_SurfaceFlags; vec4 u_UVSets; vec4 u_Emission; + vec4 u_TextureTransforms[10]; + vec4 u_Dielectric; }; [[main]] @@ -79,6 +81,7 @@ void directional_shadow_3d_fragment() if (u_SurfaceFlags.x < 0.5 && !gl_FrontFacing) { discard; } if (u_SurfaceProperties.w > 0.5 && u_SurfaceProperties.w < 1.5) { vec2 uv = u_UVSets.x > 0.5 ? v_UV1 : v_UV0; + uv = vec2(dot(u_TextureTransforms[0].xy, uv), dot(u_TextureTransforms[0].zw, uv)) + u_TextureTransforms[1].xy; float alpha = texture(sampler2D(u_BaseColorTexture, u_BaseColorSampler), uv).a * v_Alpha; if (alpha < u_SurfaceProperties.z) { discard; } } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index e0810519e..3895d2e0e 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -139,7 +139,16 @@ layout (binding = 15) uniform PBR3DUniform { vec4 u_SurfaceFlags; vec4 u_UVSets; vec4 u_Emission; + // Per texture slot (base color, metallic/roughness, normal, occlusion, emission): xyzw is a row-major 2x2 + // matrix, then xy is the offset (glTF KHR_texture_transform). + vec4 u_TextureTransforms[10]; + vec4 u_Dielectric; // rgb: reflectance of non-metals at normal incidence (0.04 for the default IOR of 1.5) }; + +vec2 applyTextureTransform(vec2 uv, int slot) { + vec4 matrix = u_TextureTransforms[slot * 2]; + return vec2(dot(matrix.xy, uv), dot(matrix.zw, uv)) + u_TextureTransforms[slot * 2 + 1].xy; +} layout (binding = 16) uniform texture2D u_OcclusionTexture; layout (binding = 0) uniform sampler u_OcclusionSampler; layout (binding = 18) uniform texture2D u_Irradiance; @@ -188,6 +197,27 @@ layout (binding = 28) uniform LocalShadow3DUniform { vec4 u_LocalAtlas; }; layout (binding = 26) uniform texture2D u_LocalShadowTexture; +layout (binding = 27) uniform LightTiles3DUniform { + mat4 u_TileProjection; + vec4 u_TileViewport; // xy pixel origin, zw pixel size of the viewport the projection maps to + vec4 u_TileInfo; // x tile size in pixels, y columns, z rows, w 1 when tile masks are valid +}; +layout (binding = 29) uniform LightTileMasks3DUniform { + uvec4 u_TileMasks[1024]; +}; + +// Bit mask of the local lights that may reach this fragment. The tile comes from projecting the view-space +// position the same way the CPU bounded each light, so it holds for any backend's fragment coordinate origin. +uint localLightMask() { + uint reachable = int(u_LightCounts.y) >= 32 ? 0xFFFFFFFFu : ((1u << uint(max(int(u_LightCounts.y), 0))) - 1u); + if (u_TileInfo.w < 0.5) { return reachable; } + vec4 clip = u_TileProjection * vec4(Input.ViewPosition, 1.0); + if (clip.w <= 0.0001) { return reachable; } + vec2 pixel = u_TileViewport.xy + vec2(clip.x / clip.w * 0.5 + 0.5, 0.5 - clip.y / clip.w * 0.5) * u_TileViewport.zw; + ivec2 tile = clamp(ivec2(floor(pixel / u_TileInfo.x)), ivec2(0), ivec2(int(u_TileInfo.y) - 1, int(u_TileInfo.z) - 1)); + int index = tile.y * int(u_TileInfo.y) + tile.x; + return u_TileMasks[index >> 2][index & 3] & reachable; +} const float PI = 3.14159265359; @@ -218,8 +248,8 @@ vec3 fresnelSchlick(float cosine, vec3 reflectanceAtNormal) { return reflectanceAtNormal + (vec3(1.0) - reflectanceAtNormal) * pow(clamp(1.0 - cosine, 0.0, 1.0), 5.0); } -vec2 materialUV(float setIndex) { - return setIndex > 0.5 ? Input.TextureCoordinate1 : Input.TextureCoordinate; +vec2 materialUV(float setIndex, int slot) { + return applyTextureTransform(setIndex > 0.5 ? Input.TextureCoordinate1 : Input.TextureCoordinate, slot); } vec2 environmentUV(vec3 direction) { @@ -245,7 +275,7 @@ vec3 environmentLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float vec3 worldNormal = normalize(mat3(u_EnvironmentInverseView) * normal); vec3 worldView = normalize(mat3(u_EnvironmentInverseView) * viewDirection); float ndotv = max(dot(normal, viewDirection), 0.0); - vec3 f0 = mix(vec3(0.04), baseColor, metallic); + vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); vec3 fresnel = f0 + (max(vec3(1.0 - roughness), f0) - f0) * pow(1.0 - ndotv, 5.0); vec3 diffuseWeight = (vec3(1.0) - fresnel) * (1.0 - metallic); vec3 irradiance = texture(sampler2D(u_Irradiance, u_IBLSampler), environmentUV(worldNormal)).rgb; @@ -275,15 +305,18 @@ vec3 materialNormal() { return normal; } - vec3 tangentNormal = texture(sampler2D(u_NormalTexture, u_NormalSampler), materialUV(u_UVSets.z)).xyz * 2.0 - 1.0; + vec3 tangentNormal = texture(sampler2D(u_NormalTexture, u_NormalSampler), materialUV(u_UVSets.z, 2)).xyz * 2.0 - 1.0; tangentNormal.xy *= u_SurfaceProperties.x; - if (Input.TextureFlags.w > 0.5 && u_UVSets.z < 0.5) { + // Authored tangents follow the mesh UVs, so they only hold while the normal map's transform keeps its axes. + vec4 normalMatrix = u_TextureTransforms[4]; + bool keepsTangents = abs(normalMatrix.y) < 0.0001 && abs(normalMatrix.z) < 0.0001 && normalMatrix.x > 0.0 && normalMatrix.w > 0.0; + if (Input.TextureFlags.w > 0.5 && u_UVSets.z < 0.5 && keepsTangents) { vec3 tangent = normalize(Input.ViewTangent.xyz - normal * dot(normal, Input.ViewTangent.xyz)); vec3 bitangent = normalize(cross(normal, tangent)) * Input.ViewTangent.w; return normalize(mat3(tangent, bitangent, normal) * tangentNormal); } - return normalize(cotangentFrame(normal, Input.ViewPosition, materialUV(u_UVSets.z)) * tangentNormal); + return normalize(cotangentFrame(normal, Input.ViewPosition, materialUV(u_UVSets.z, 2)) * tangentNormal); } float cascadeVisibility(int cascade, vec3 normal, vec3 lightDirection) { @@ -334,7 +367,7 @@ vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 b float nl = max(dot(normal, lightDirection), 0.0), nv = max(dot(normal, viewDirection), 0.0); if (nl <= 0.0 || nv <= 0.0) { return vec3(0.0); } vec3 halfway = normalize(viewDirection + lightDirection); - vec3 f0 = mix(vec3(0.04), baseColor, metallic); + vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); float distribution = distributionGGX(normal, halfway, roughness); float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); @@ -379,8 +412,11 @@ vec3 additionalLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float m result += evaluateDirect(normal, viewDirection, normalize(u_AdditionalDirections[i].xyz), baseColor, metallic, roughness, u_AdditionalRadiance[i].rgb * max(u_AdditionalDirections[i].w, 0.0)); } - for (int i = 0; i < 32; ++i) { - if (i >= int(u_LightCounts.y)) { break; } + uint mask = localLightMask(); + for (int n = 0; n < 32; ++n) { + if (mask == 0u) { break; } + int i = findLSB(mask); + mask &= mask - 1u; LocalLight3D light = u_LocalLights[i]; vec3 delta = light.positionRange.xyz - Input.ViewPosition; float distance = length(delta), range = light.positionRange.w; @@ -452,7 +488,7 @@ void flat3d_fragment() if (u_SurfaceFlags.x < 0.5 && !gl_FrontFacing) { discard; } vec4 baseColor = Input.Color; if (Input.TextureFlags.x > 0.5) { - vec4 sampledBaseColor = texture(sampler2D(u_BaseColorTexture, u_BaseColorSampler), materialUV(u_UVSets.x)); + vec4 sampledBaseColor = texture(sampler2D(u_BaseColorTexture, u_BaseColorSampler), materialUV(u_UVSets.x, 0)); baseColor *= vec4(srgbToLinear(sampledBaseColor.rgb), sampledBaseColor.a); } @@ -463,7 +499,7 @@ void flat3d_fragment() if (Input.TextureFlags.y > 0.5) { vec4 metallicRoughness = texture( sampler2D(u_MetallicRoughnessTexture, u_MetallicRoughnessSampler), - materialUV(u_UVSets.y) + materialUV(u_UVSets.y, 1) ); roughness = clamp(roughness * metallicRoughness.g, 0.04, 1.0); metallic = clamp(metallic * metallicRoughness.b, 0.0, 1.0); @@ -508,7 +544,7 @@ void flat3d_fragment() float normalLight = max(dot(normal, lightDirection), 0.0); float normalView = max(dot(normal, viewDirection), 0.0); - vec3 reflectanceAtNormal = mix(vec3(0.04), baseColor.rgb, metallic); + vec3 reflectanceAtNormal = mix(u_Dielectric.rgb, baseColor.rgb, metallic); vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), reflectanceAtNormal); float distribution = distributionGGX(normal, halfway, roughness); float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); @@ -521,7 +557,7 @@ void flat3d_fragment() directLighting += additionalLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); vec3 ambient = baseColor.rgb * (1.0 - metallic) * max(u_LightRadianceAmbient.w, 0.0); if (u_IBLParameters.x > 0.5) { ambient = environmentLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); } - float occlusion = u_SurfaceFlags.y > 0.5 ? texture(sampler2D(u_OcclusionTexture, u_OcclusionSampler), materialUV(u_UVSets.w)).r : 1.0; + float occlusion = u_SurfaceFlags.y > 0.5 ? texture(sampler2D(u_OcclusionTexture, u_OcclusionSampler), materialUV(u_UVSets.w, 3)).r : 1.0; ambient *= mix(1.0, occlusion, u_SurfaceProperties.y); float emissiveVisibility = 1.0; if (Input.EmissiveLightThreshold >= 0.0) { @@ -533,7 +569,7 @@ void flat3d_fragment() } vec3 emissive = srgbToLinear(texture( sampler2D(u_EmissiveTexture, u_EmissiveSampler), - materialUV(u_Emission.x) + materialUV(u_Emission.x, 4) ).rgb) * u_EmissiveFactor.rgb * Input.EmissiveStrength * emissiveVisibility; color = vec4(directLighting + ambient + emissive, opacity); diff --git a/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl index 997c71701..c8b8d5f9a 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl @@ -65,6 +65,8 @@ layout (binding = 4) uniform texture2D u_BaseColor; layout (binding = 7) uniform sampler u_Sampler; layout (binding = 15) uniform PBR3DUniform { vec4 u_EmissiveFactor; vec4 u_SurfaceProperties; vec4 u_SurfaceFlags; vec4 u_UVSets; vec4 u_Emission; + vec4 u_TextureTransforms[10]; + vec4 u_Dielectric; }; [[main]] void motion_fragment() { @@ -75,7 +77,11 @@ void motion_fragment() { if ((values[y * 4 + x] + 0.5) / 16.0 < v_Fade) { discard; } } float alpha = v_ColorFlags.x; - if (v_ColorFlags.y > 0.5) { alpha *= texture(sampler2D(u_BaseColor, u_Sampler), u_UVSets.x > 0.5 ? v_UV1 : v_UV).a; } + if (v_ColorFlags.y > 0.5) { + vec2 uv = u_UVSets.x > 0.5 ? v_UV1 : v_UV; + uv = vec2(dot(u_TextureTransforms[0].xy, uv), dot(u_TextureTransforms[0].zw, uv)) + u_TextureTransforms[1].xy; + alpha *= texture(sampler2D(u_BaseColor, u_Sampler), uv).a; + } if (u_SurfaceProperties.w > 0.5 && u_SurfaceProperties.w < 1.5 && alpha < u_SurfaceProperties.z) { discard; } bool valid = v_CurrentClip.w > 0.00001 && v_PreviousClip.w > 0.00001; vec2 motion = valid ? (v_PreviousClip.xy / v_PreviousClip.w - v_CurrentClip.xy / v_CurrentClip.w) * vec2(0.5, -0.5) : vec2(0.0); diff --git a/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.glsl b/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.glsl index b7f1e0b9b..a70a2dbfd 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.glsl @@ -31,6 +31,7 @@ layout (binding = 12) uniform texture2D u_Bloom; layout (binding = 13) uniform texture2D u_GradingLUT; layout (binding = 14) uniform sampler u_GradingSampler; layout (binding = 15) uniform texture2D u_ExposureState; +layout (binding = 16) uniform texture2D u_Volumetric; layout (binding = 4) uniform Environment3DUniform { mat4 u_Projection; @@ -49,6 +50,11 @@ layout (binding = 4) uniform Environment3DUniform { vec4 u_PostProcess; // x exposure scale, y bloom intensity, z tone mapper (see Tonemapping.glsl), w LUT intensity vec4 u_Grading; // x saturation, y contrast, z vignette intensity, w vignette radius vec4 u_Vignette; // x vignette smoothness, y 1 when automatic exposure is bound + vec4 u_FogColor; // rgb sRGB fog color, w density (0 = fog off) + vec4 u_FogShape; // x height falloff, y base height, z start distance, w maximum opacity + vec4 u_FogRange; // x sky influence, y sun exponent, z 1 when volumetric light shafts are bound + vec4 u_FogSun; // xyz world direction to the sun, w sun in-scattering strength + vec4 u_FogSunColor; // rgb sun radiance times intensity }; const float PI = 3.14159265359; @@ -75,6 +81,60 @@ vec3 presentColor(vec3 linearColor) { return adaApplyLut(adaOutputTransform(exposed, u_PostProcess.z), u_PostProcess.w); } +// View-space depth the half-resolution volumetric pass saw at `uv`; sky counts as very far. +float volumetricDepthAt(vec2 uv) { + vec3 position = texture(sampler2D(u_ViewPositionMetallic, u_GBufferSampler), uv).xyz; + return length(position) > 0.0001 ? position.z : 10000.0; +} + +// Shadowed sun light marched through the fog by the half-resolution volumetric pass. The four surrounding +// half-resolution texels are weighted by how close their depth is to this pixel's, so shafts do not bleed +// across silhouettes. +vec3 volumetricScatter(float depth) { + if (u_FogRange.z < 0.5) { + return vec3(0.0); + } + vec2 size = vec2(textureSize(sampler2D(u_Volumetric, u_LinearSampler), 0)); + vec2 base = floor(v_UV * size - 0.5); + vec2 fraction = fract(v_UV * size - 0.5); + vec3 sum = vec3(0.0); + vec3 plain = vec3(0.0); + float weights = 0.0; + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) { + vec2 uv = clamp((base + vec2(float(x), float(y)) + 0.5) / size, 0.5 / size, vec2(1.0) - 0.5 / size); + float bilinear = (x == 0 ? 1.0 - fraction.x : fraction.x) * (y == 0 ? 1.0 - fraction.y : fraction.y); + vec3 value = texture(sampler2D(u_Volumetric, u_GBufferSampler), uv).rgb; + float weight = bilinear * exp(-abs(volumetricDepthAt(uv) - depth) / max(0.1 * depth, 0.05)); + sum += value * weight; + plain += value * bilinear; + weights += weight; + } + } + return weights > 0.001 ? sum / weights : plain; +} + +// Blends exponential height fog into linear HDR color. `viewPosition` is the view-space position of the surface +// (or a far point along the ray for the sky, flagged by `isSky`) and `skyAmount` scales fog for sky pixels. +vec3 applyFog(vec3 linearColor, vec3 viewPosition, float skyAmount, bool isSky) { + if (u_FogColor.w <= 0.0) { + return linearColor; + } + float travel = max(length(viewPosition) - u_FogShape.z, 0.0); + vec3 worldDirection = normalize((u_InverseView * vec4(viewPosition, 0.0)).xyz); + float falloff = u_FogShape.x; + float cameraHeight = u_InverseView[3].y; + float rise = falloff * worldDirection.y; + // Integral of exp(-falloff * (height - base)) along the ray, with the series limit for a flat ray. + float exponent = clamp(rise * travel, -60.0, 60.0); + float pathFactor = abs(rise * travel) > 0.0001 ? (1.0 - exp(-exponent)) / rise : travel; + float baseDensity = u_FogColor.w * exp(clamp(-falloff * (cameraHeight - u_FogShape.y), -60.0, 60.0)); + float opacity = min(1.0 - exp(-baseDensity * pathFactor), u_FogShape.w) * skyAmount; + float sunAlignment = pow(max(dot(worldDirection, u_FogSun.xyz), 0.0), u_FogRange.y); + vec3 scattered = srgbToLinear(u_FogColor.rgb) + u_FogSunColor.rgb * (u_FogSun.w * sunAlignment); + return mix(linearColor, scattered, opacity) + volumetricScatter(isSky ? 10000.0 : viewPosition.z); +} + vec3 proceduralSky(vec3 direction) { float height = clamp(direction.y, -1.0, 1.0); float horizonBlend = pow(abs(height), 0.55); @@ -241,7 +301,7 @@ void ssr_fragment() { vec2 ndc = vec2(v_UV.x * 2.0 - 1.0, 1.0 - v_UV.y * 2.0); vec4 viewFar = u_InverseProjection * vec4(ndc, 1.0, 1.0); vec3 viewRay = normalize(viewFar.xyz / viewFar.w); - o_Color = vec4(presentColor(sampleSky(viewRay)), 1.0); + o_Color = vec4(presentColor(applyFog(sampleSky(viewRay), viewRay * 1000.0, u_FogRange.x, true)), 1.0); return; } @@ -250,7 +310,7 @@ void ssr_fragment() { vec3 indirect = texture(sampler2D(u_Indirect, u_LinearSampler), v_UV).rgb; baseColor = max(baseColor - indirect * (1.0 - ao), vec3(0.0)); // The PBR pass already integrated diffuse and specular IBL, including AO and material F0. - if (u_IBL.x > 0.5) { o_Color = vec4(presentColor(baseColor), 1.0); return; } + if (u_IBL.x > 0.5) { o_Color = vec4(presentColor(applyFog(baseColor, positionMetallic.xyz, 1.0, false)), 1.0); return; } vec3 normal = normalize(normalRoughness.xyz); float roughness = clamp(normalRoughness.w, 0.04, 1.0); float metallic = clamp(positionMetallic.w, 0.0, 1.0); @@ -275,5 +335,5 @@ void ssr_fragment() { } vec3 ambient = sampleSky(normal) * (0.035 + 0.035 * (1.0 - roughness)); vec3 reflectionResult = mix(environment, reflectedColor, visibility); - o_Color = vec4(presentColor(baseColor + (ambient + reflectionResult * reflectivity) * ao), 1.0); + o_Color = vec4(presentColor(applyFog(baseColor + (ambient + reflectionResult * reflectivity) * ao, positionMetallic.xyz, 1.0, false)), 1.0); } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.glsl new file mode 100644 index 000000000..72537b1f7 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.glsl @@ -0,0 +1,15 @@ +#version 450 core +#pragma stage : vert +[[main]] +void shadow_clear_vertex() { + vec2 uv = vec2(float(gl_VertexIndex >> 1u), float(gl_VertexIndex & 1u)) * 2.0; + gl_Position = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); +} + +#version 450 core +#pragma stage : frag +layout (location = 0) out vec4 o_Color; +[[main]] +void shadow_clear_fragment() { + o_Color = vec4(1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.glsl new file mode 100644 index 000000000..674f60e07 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.glsl @@ -0,0 +1,96 @@ +#version 450 core +#pragma stage : vert +layout (location = 0) out vec2 v_UV; +[[main]] +void volumetric_fog_vertex() { + vec2 uv = vec2(float(gl_VertexIndex >> 1u), float(gl_VertexIndex & 1u)) * 2.0; + gl_Position = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); + v_UV = uv; +} + +#version 450 core +#pragma stage : frag +layout (location = 0) in vec2 v_UV; +layout (location = 0) out vec4 o_Color; +layout (binding = 0) uniform texture2D u_ViewPositionMetallic; +layout (binding = 1) uniform texture2D u_DirectionalShadowTexture; +layout (binding = 2) uniform sampler u_Nearest; +layout (binding = 4) uniform VolumetricFog3DUniform { + mat4 u_InverseView; + mat4 u_InverseProjection; + mat4 u_ShadowViewProjection0; + mat4 u_ShadowViewProjection1; + mat4 u_ShadowViewProjection2; + vec4 u_CascadeSplits; // xyz far view depth of each cascade, w cascade count + vec4 u_ShadowAtlas; // xy shadow texel size, z constant bias, w 1 when shadows are enabled + vec4 u_SunDirection; // xyz world direction to the sun, w anisotropy + vec4 u_SunRadiance; // rgb radiance times intensity times the pass intensity + vec4 u_Medium; // x density, y height falloff, z base height, w start distance + vec4 u_March; // x steps, y maximum distance, z frame jitter +}; + +const float PI = 3.14159265359; + +float interleavedGradientNoise(vec2 pixel) { + return fract(52.9829189 * fract(dot(pixel, vec2(0.06711056, 0.00583715)))); +} + +float henyeyGreenstein(float cosine, float g) { + float denominator = 1.0 + g * g - 2.0 * g * cosine; + return (1.0 - g * g) / (4.0 * PI * pow(max(denominator, 0.0001), 1.5)); +} + +// Whether the sun reaches a world-space point, from the cascade covering its view depth. +float sunVisibility(vec3 world, float viewDepth) { + if (u_ShadowAtlas.w < 0.5 || viewDepth > u_CascadeSplits.z) { return 1.0; } + int count = int(u_CascadeSplits.w); + int cascade = min(viewDepth <= u_CascadeSplits.x ? 0 : (viewDepth <= u_CascadeSplits.y ? 1 : 2), count - 1); + vec4 position; + if (cascade == 0) { position = u_ShadowViewProjection0 * vec4(world, 1.0); } + else if (cascade == 1) { position = u_ShadowViewProjection1 * vec4(world, 1.0); } + else { position = u_ShadowViewProjection2 * vec4(world, 1.0); } + vec3 projected = position.xyz / position.w; + vec2 localUV = projected.xy * vec2(0.5, -0.5) + 0.5; + if (projected.z < 0.0 || projected.z > 1.0 || any(lessThan(localUV, vec2(0.0))) || any(greaterThan(localUV, vec2(1.0)))) { return 1.0; } + float cascadeCount = max(u_CascadeSplits.w, 1.0); + vec2 uv = vec2((localUV.x + float(cascade)) / cascadeCount, localUV.y); + vec2 minimum = vec2(float(cascade) / cascadeCount, 0.0) + u_ShadowAtlas.xy * 0.5; + vec2 maximum = vec2(float(cascade + 1) / cascadeCount, 1.0) - u_ShadowAtlas.xy * 0.5; + float stored = textureLod(sampler2D(u_DirectionalShadowTexture, u_Nearest), clamp(uv, minimum, maximum), 0.0).r; + return projected.z - u_ShadowAtlas.z <= stored ? 1.0 : 0.0; +} + +[[main]] +void volumetric_fog_fragment() { + vec4 positionMetallic = textureLod(sampler2D(u_ViewPositionMetallic, u_Nearest), v_UV, 0.0); + vec2 ndc = vec2(v_UV.x * 2.0 - 1.0, 1.0 - v_UV.y * 2.0); + vec4 viewFar = u_InverseProjection * vec4(ndc, 1.0, 1.0); + vec3 viewRay = normalize(viewFar.xyz / viewFar.w); + bool hasSurface = length(positionMetallic.xyz) > 0.0001; + float surfaceDistance = hasSurface ? length(positionMetallic.xyz) : u_March.y; + float rangeEnd = min(surfaceDistance, u_March.y); + float rangeStart = min(u_Medium.w, rangeEnd); + int steps = int(u_March.x); + float stepLength = (rangeEnd - rangeStart) / float(steps); + if (stepLength <= 0.0) { + o_Color = vec4(0.0); + return; + } + vec3 worldDirection = normalize((u_InverseView * vec4(viewRay, 0.0)).xyz); + vec3 origin = u_InverseView[3].xyz; + float phase = henyeyGreenstein(dot(worldDirection, u_SunDirection.xyz), u_SunDirection.w); + float jitter = fract(interleavedGradientNoise(gl_FragCoord.xy) + u_March.z); + + float transmittance = 1.0; + float scattered = 0.0; + for (int index = 0; index < 64; ++index) { + if (index >= steps) { break; } + float travel = rangeStart + (float(index) + jitter) * stepLength; + vec3 world = origin + worldDirection * travel; + float density = u_Medium.x * exp(clamp(-u_Medium.y * (world.y - u_Medium.z), -60.0, 60.0)); + float step = density * stepLength; + transmittance *= exp(-step); + scattered += step * transmittance * sunVisibility(world, travel * viewRay.z); + } + o_Color = vec4(u_SunRadiance.rgb * (scattered * phase), 1.0); +} diff --git a/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift b/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift index 13fb60101..3b664f575 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift @@ -55,6 +55,7 @@ public struct Core3DPlugin: Plugin { graph.addNode(AutoExposure3DRenderNode()) graph.addNode(Bloom3DRenderNode()) graph.addNode(AntiAliasing3DRenderNode(notifiesCompletion: !includes2D)) + graph.addNode(VolumetricFog3DRenderNode()) graph.addNode(ScreenSpaceReflectionRenderNode(notifiesCompletion: !includes2D)) graph.addNode(Temporal3DRenderNode()) graph.addNode(DepthOfField3DRenderNode(notifiesCompletion: !includes2D)) @@ -76,6 +77,8 @@ public struct Core3DPlugin: Plugin { graph.addNodeEdge(from: DirectionalShadow3DRenderNode.name, to: Main3DRenderNode.name) graph.addNodeEdge(from: Main3DRenderNode.name, to: ScreenSpaceReflectionRenderNode.name) graph.addNodeEdge(from: Main3DRenderNode.name, to: ScreenSpaceAO3DRenderNode.name) + graph.addNodeEdge(from: Main3DRenderNode.name, to: VolumetricFog3DRenderNode.name) + graph.addNodeEdge(from: VolumetricFog3DRenderNode.name, to: ScreenSpaceReflectionRenderNode.name) graph.addNodeEdge(from: ScreenSpaceAO3DRenderNode.name, to: AutoExposure3DRenderNode.name) graph.addNodeEdge(from: AutoExposure3DRenderNode.name, to: Bloom3DRenderNode.name) graph.addNodeEdge(from: Bloom3DRenderNode.name, to: ScreenSpaceReflectionRenderNode.name) @@ -105,7 +108,9 @@ public struct Core3DPlugin: Plugin { .insertResource(LocalShadow3DViews()) .insertResource(Render3DLightStatistics()) .insertResource(LocalShadow3DScratch()) + .insertResource(LocalShadow3DClear(device: renderDevice)) .insertResource(LocalLighting3DGPUScratch()) + .insertResource(LightTiles3DScratch()) .insertResource(RenderPipelines(configurator: LocalShadow3DPipeline())) .insertResource(ExtractedMesh3DSources()) .insertResource(VisibleMesh3DLists()) @@ -127,6 +132,7 @@ public struct Core3DPlugin: Plugin { .insertResource(Bloom3DViews(device: renderDevice)) .insertResource(AutoExposure3DViews(device: renderDevice)) .insertResource(DepthOfField3DViews(device: renderDevice)) + .insertResource(VolumetricFog3DViews(device: renderDevice)) .insertResource(ExtractedDebugLines3D()) .insertResource(DebugDraw3DResources()) .addSystem(ExtractDebugDraw3DSystem.self, on: .extract) diff --git a/Sources/AdaCorePipelines/CorePipelines/Environment3D.swift b/Sources/AdaCorePipelines/CorePipelines/Environment3D.swift index 351a9f8f8..204c03ca8 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Environment3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Environment3D.swift @@ -124,6 +124,10 @@ public struct Environment3D: Codable, Sendable { public var debugView: DebugView3D = DebugView3D.none /// Exposure and bloom applied before tone mapping. public var postProcess: PostProcess3D = PostProcess3D() + /// Distance and height fog applied in linear HDR before tone mapping. + public var fog: Fog3D = Fog3D() + /// Evaluate only the local lights that reach each screen tile. Disabling it falls back to the flat per-pixel loop. + public var tiledLocalLights: Bool = true /// Flat reflected controls for script and inspector access to render quality settings. public var shadowsEnabled: Bool { @@ -200,6 +204,14 @@ public struct Environment3D: Codable, Sendable { get { postProcess.bloom.threshold } set { postProcess.bloom.threshold = newValue } } + public var fogEnabled: Bool { + get { fog.isEnabled } + set { fog.isEnabled = newValue } + } + public var fogDensity: Float { + get { fog.density } + set { fog.density = newValue } + } public var localShadowsEnabled: Bool { get { localShadows.isEnabled } set { localShadows.isEnabled = newValue } @@ -244,7 +256,7 @@ public struct Environment3D: Codable, Sendable { private enum CodingKeys: CodingKey { case skybox, screenSpaceReflection, imageBasedLighting, shadows, - ambientOcclusion, antiAliasing, meshVisibility, localShadows, debugView, postProcess + ambientOcclusion, antiAliasing, meshVisibility, localShadows, debugView, postProcess, fog, tiledLocalLights } public init(from decoder: Decoder) throws { @@ -259,6 +271,8 @@ public struct Environment3D: Codable, Sendable { localShadows = try container.decodeIfPresent(LocalShadowSettings3D.self, forKey: .localShadows) ?? LocalShadowSettings3D() debugView = try container.decodeIfPresent(DebugView3D.self, forKey: .debugView) ?? DebugView3D.none postProcess = try container.decodeIfPresent(PostProcess3D.self, forKey: .postProcess) ?? PostProcess3D() + fog = try container.decodeIfPresent(Fog3D.self, forKey: .fog) ?? Fog3D() + tiledLocalLights = try container.decodeIfPresent(Bool.self, forKey: .tiledLocalLights) ?? true } } diff --git a/Sources/AdaCorePipelines/CorePipelines/Fog3D.swift b/Sources/AdaCorePipelines/CorePipelines/Fog3D.swift new file mode 100644 index 000000000..89018ed3b --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/Fog3D.swift @@ -0,0 +1,160 @@ +import AdaRender +import AdaUtils +import Foundation +import Math + +/// Shadowed sun light shafts marched through the fog medium. +/// +/// A half-resolution pass marches each view ray, samples the primary directional light's cascaded shadow map and +/// adds the in-scattered light to the frame before tone mapping. It needs `Fog3D` enabled, whose `density` and +/// height falloff define the medium, and replaces the unshadowed `sunInscattering` glow while it runs. +public struct VolumetricFog3D: Codable, Sendable, Equatable { + /// Whether the pass runs. Off by default; a disabled effect allocates nothing. + public var isEnabled: Bool + /// Strength of the in-scattered sun light, as a multiple of the sun's radiance. + public var intensity: Float + /// Henyey-Greenstein anisotropy (`-0.95...0.95`). Positive values brighten shafts when looking toward the sun. + public var anisotropy: Float + /// Samples per ray (`4...64`). More samples reduce noise and banding. + public var steps: Int + /// Farthest distance marched from the camera. Closer geometry ends the ray earlier. + public var maxDistance: Float + + public init(isEnabled: Bool = false, intensity: Float = 1, anisotropy: Float = 0.4, steps: Int = 24, maxDistance: Float = 60) { + self.isEnabled = isEnabled + self.intensity = intensity + self.anisotropy = anisotropy + self.steps = steps + self.maxDistance = maxDistance + } + + private enum CodingKeys: CodingKey { case isEnabled, intensity, anisotropy, steps, maxDistance } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = VolumetricFog3D() + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? defaults.isEnabled + intensity = try container.decodeIfPresent(Float.self, forKey: .intensity) ?? defaults.intensity + anisotropy = try container.decodeIfPresent(Float.self, forKey: .anisotropy) ?? defaults.anisotropy + steps = try container.decodeIfPresent(Int.self, forKey: .steps) ?? defaults.steps + maxDistance = try container.decodeIfPresent(Float.self, forKey: .maxDistance) ?? defaults.maxDistance + } + + /// Settings clamped to ranges the shader handles, replacing non-finite values with defaults. + var sanitized: VolumetricFog3D { + let defaults = VolumetricFog3D() + return VolumetricFog3D( + isEnabled: isEnabled, + intensity: finiteRenderValue(intensity, fallback: defaults.intensity, range: 0...16), + anisotropy: finiteRenderValue(anisotropy, fallback: defaults.anisotropy, range: -0.95...0.95), + steps: min(max(steps, 4), 64), + maxDistance: finiteRenderValue(maxDistance, fallback: defaults.maxDistance, range: 1...100_000) + ) + } +} + +/// Distance and exponential height fog, applied to lit geometry in linear HDR before exposure and tone mapping. +/// +/// Optical depth along a view ray is the integral of `density * exp(-heightFalloff * (y - baseHeight))`, so fog +/// is thickest at and below `baseHeight` and thins out above it. A `heightFalloff` of `0` gives uniform +/// distance fog. Fog scatters ambient light in `color` and, when `sunInscattering` is above zero, brightens +/// towards the primary directional light. +public struct Fog3D: Codable, Sendable, Equatable { + /// Whether fog is applied. Off by default. + public var isEnabled: Bool + /// Fog color authored in sRGB, like the skybox colors. + public var color: Color + /// Extinction per world unit at `baseHeight`. `0.02` is light haze; `0.2` is dense fog. + public var density: Float + /// How quickly fog thins with height, per world unit. `0` disables the height term. + public var heightFalloff: Float + /// World-space height where fog has its full `density`. + public var baseHeight: Float + /// Distance from the camera before fog starts. + public var startDistance: Float + /// Largest fraction of a pixel that fog can replace (`0...1`). + public var maximumOpacity: Float + /// How much fog covers the sky (`0...1`). `0` leaves the sky untouched. + public var skyInfluence: Float + /// Strength of the glow towards the directional light, as a multiple of its radiance. + public var sunInscattering: Float + /// Tightness of the sun glow. Larger values give a narrower cone. + public var sunExponent: Float + /// Shadowed light shafts through this fog. + public var volumetric: VolumetricFog3D + + public init( + isEnabled: Bool = false, + color: Color = Color(red: 0.62, green: 0.72, blue: 0.82), + density: Float = 0.02, + heightFalloff: Float = 0, + baseHeight: Float = 0, + startDistance: Float = 0, + maximumOpacity: Float = 1, + skyInfluence: Float = 0, + sunInscattering: Float = 0, + sunExponent: Float = 8, + volumetric: VolumetricFog3D = VolumetricFog3D() + ) { + self.isEnabled = isEnabled + self.color = color + self.density = density + self.heightFalloff = heightFalloff + self.baseHeight = baseHeight + self.startDistance = startDistance + self.maximumOpacity = maximumOpacity + self.skyInfluence = skyInfluence + self.sunInscattering = sunInscattering + self.sunExponent = sunExponent + self.volumetric = volumetric + } + + private enum CodingKeys: CodingKey { + case isEnabled, color, density, heightFalloff, baseHeight, startDistance, maximumOpacity, skyInfluence, sunInscattering, sunExponent, volumetric + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = Fog3D() + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? defaults.isEnabled + color = try container.decodeIfPresent(Color.self, forKey: .color) ?? defaults.color + density = try container.decodeIfPresent(Float.self, forKey: .density) ?? defaults.density + heightFalloff = try container.decodeIfPresent(Float.self, forKey: .heightFalloff) ?? defaults.heightFalloff + baseHeight = try container.decodeIfPresent(Float.self, forKey: .baseHeight) ?? defaults.baseHeight + startDistance = try container.decodeIfPresent(Float.self, forKey: .startDistance) ?? defaults.startDistance + maximumOpacity = try container.decodeIfPresent(Float.self, forKey: .maximumOpacity) ?? defaults.maximumOpacity + skyInfluence = try container.decodeIfPresent(Float.self, forKey: .skyInfluence) ?? defaults.skyInfluence + sunInscattering = try container.decodeIfPresent(Float.self, forKey: .sunInscattering) ?? defaults.sunInscattering + sunExponent = try container.decodeIfPresent(Float.self, forKey: .sunExponent) ?? defaults.sunExponent + volumetric = try container.decodeIfPresent(VolumetricFog3D.self, forKey: .volumetric) ?? defaults.volumetric + } + + /// Whether fog can change the image. A camera that returns `false` pays nothing for fog. + var changesImage: Bool { + let settings = sanitized + return isEnabled && settings.density > 0 && settings.maximumOpacity > 0 + } + + /// Whether the light-shaft pass has work to do. + var volumetricActive: Bool { + changesImage && volumetric.isEnabled && volumetric.sanitized.intensity > 0 + } + + /// Settings clamped to ranges the shader handles, replacing non-finite values with defaults. + var sanitized: Fog3D { + let defaults = Fog3D() + return Fog3D( + isEnabled: isEnabled, + color: color, + density: finiteRenderValue(density, fallback: defaults.density, range: 0...10), + heightFalloff: finiteRenderValue(heightFalloff, fallback: defaults.heightFalloff, range: 0...10), + baseHeight: finiteRenderValue(baseHeight, fallback: defaults.baseHeight, range: -100_000...100_000), + startDistance: finiteRenderValue(startDistance, fallback: defaults.startDistance, range: 0...100_000), + maximumOpacity: finiteRenderValue(maximumOpacity, fallback: defaults.maximumOpacity, range: 0...1), + skyInfluence: finiteRenderValue(skyInfluence, fallback: defaults.skyInfluence, range: 0...1), + sunInscattering: finiteRenderValue(sunInscattering, fallback: defaults.sunInscattering, range: 0...16), + sunExponent: finiteRenderValue(sunExponent, fallback: defaults.sunExponent, range: 1...256), + volumetric: volumetric.sanitized + ) + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/LightTiles3D.swift b/Sources/AdaCorePipelines/CorePipelines/LightTiles3D.swift new file mode 100644 index 000000000..4e05ddf81 --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/LightTiles3D.swift @@ -0,0 +1,140 @@ +import AdaECS +@_spi(Internal) import AdaRender +import Math + +/// Screen-space tiles that each carry a bit mask of the local lights reaching them. +/// +/// The main pass evaluates only the lights set in the pixel's tile instead of every light in the frame. A light +/// reaches a tile when the screen-space bounds of its range sphere overlap it, so the mask is conservative and +/// never drops a light that could contribute: lights outside their range already contribute nothing, which keeps +/// the lit result identical to the flat loop. +enum LightTiles3DMath { + /// Local lights per frame, one bit each in a 32-bit mask. + static let maximumLights = 32 + /// Upper bound on tile count, set by the size of the GPU mask buffer. + static let maximumTiles = 4096 + static let smallestTileSize = 32 + /// Nearest view depth a light sphere may reach before the whole screen counts as covered. + private static let nearDepth: Float = 0.05 + + struct Layout: Equatable { + var tileSize: Int + var columns: Int + var rows: Int + var count: Int { columns * rows } + } + + /// The smallest power-of-two multiple of 32 pixels that keeps the tile count within `maximumTiles`. + static func layout(width: Int, height: Int) -> Layout { + var size = smallestTileSize + func grid(_ size: Int) -> Layout { + Layout(tileSize: size, columns: max(1, (width + size - 1) / size), rows: max(1, (height + size - 1) / size)) + } + while grid(size).count > maximumTiles { + size *= 2 + } + return grid(size) + } + + /// A mask with the first `count` lights set, used when tiles are not in play. + static func allLights(_ count: Int) -> UInt32 { + count >= maximumLights ? UInt32.max : (UInt32(1) << UInt32(max(count, 0))) - 1 + } + + /// One mask per tile for lights given as view-space sphere centers and ranges. + /// + /// `projection` maps view space to clip space (the one the main pass draws with) and `viewport` is the pixel + /// rectangle it renders into. + static func masks( + spheres: [(center: Vector3, range: Float)], + projection: Transform3D, + viewport: Rect, + layout: Layout + ) -> [UInt32] { + var masks = [UInt32](repeating: 0, count: layout.count) + for (index, sphere) in spheres.prefix(maximumLights).enumerated() { + let bit = UInt32(1) << UInt32(index) + guard sphere.range > 0, sphere.center.z + sphere.range > 0 else { + continue + } + guard let bounds = screenBounds(of: sphere, projection: projection, viewport: viewport) else { + for tile in masks.indices { masks[tile] |= bit } + continue + } + let size = Float(layout.tileSize) + let firstColumn = max(0, Int((bounds.minX - 1) / size)), lastColumn = min(layout.columns - 1, Int((bounds.maxX + 1) / size)) + let firstRow = max(0, Int((bounds.minY - 1) / size)), lastRow = min(layout.rows - 1, Int((bounds.maxY + 1) / size)) + guard bounds.maxX >= 0, bounds.maxY >= 0, firstColumn <= lastColumn, firstRow <= lastRow else { + continue + } + for row in firstRow...lastRow { + for column in firstColumn...lastColumn { + masks[row * layout.columns + column] |= bit + } + } + } + return masks + } + + /// Pixel bounds of the sphere's view-space bounding box, or `nil` when it may reach the camera plane and so + /// can cover any pixel. + private static func screenBounds( + of sphere: (center: Vector3, range: Float), + projection: Transform3D, + viewport: Rect + ) -> (minX: Float, minY: Float, maxX: Float, maxY: Float)? { + guard sphere.center.z - sphere.range > nearDepth else { + return nil + } + var bounds = (minX: Float.infinity, minY: Float.infinity, maxX: -Float.infinity, maxY: -Float.infinity) + for corner in 0..<8 { + let point = Vector4( + sphere.center.x + (corner & 1 == 0 ? -sphere.range : sphere.range), + sphere.center.y + (corner & 2 == 0 ? -sphere.range : sphere.range), + sphere.center.z + (corner & 4 == 0 ? -sphere.range : sphere.range), + 1 + ) + let clip = projection * point + guard clip.w > 0.0001 else { + return nil + } + let pixelX = viewport.origin.x + (clip.x / clip.w * 0.5 + 0.5) * viewport.size.width + let pixelY = viewport.origin.y + (0.5 - clip.y / clip.w * 0.5) * viewport.size.height + bounds = (min(bounds.minX, pixelX), min(bounds.minY, pixelY), max(bounds.maxX, pixelX), max(bounds.maxY, pixelY)) + } + return bounds + } +} + +/// Frame-buffered tile masks, one ring per camera. +struct LightTiles3DScratch: Resource { + /// `uvec4` entries in the shader's mask array (`maximumTiles / 4`). + static let maskVectors = LightTiles3DMath.maximumTiles / 4 + + private var buffers: [Entity.ID: (buffers: [BufferData>], index: Int)] = [:] + private let frames = max(1, unsafe RenderEngine.configurations.maxFramesInFlight) + + mutating func retainViews(_ views: [Entity.ID]) { + for view in buffers.keys where !views.contains(view) { buffers.removeValue(forKey: view) } + } + + mutating func write(view: Entity.ID, masks: [UInt32], device: RenderDevice) -> BufferData> { + var entry = buffers.removeValue(forKey: view) ?? ( + (0..](repeating: .zero, count: Self.maskVectors)) }, + 0 + ) + entry.index = (entry.index + 1) % frames + for vector in 0...zero + for lane in 0..<4 { + let tile = vector * 4 + lane + packed[lane] = tile < masks.count ? masks[tile] : 0 + } + entry.buffers[entry.index].elements[vector] = packed + } + entry.buffers[entry.index].write(to: device) + let output = entry.buffers[entry.index] + buffers[view] = entry + return output + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift b/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift index ac80556c1..4ebe6cf6d 100644 --- a/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift @@ -6,10 +6,24 @@ import Math public struct LocalShadowSettings3D: Codable, Sendable { public var isEnabled: Bool public var maximumLights: Int + /// Keep the shadow faces of lights and casters that did not change since the last frame instead of redrawing + /// them. Faces with skinned casters are always redrawn. + public var reusesStaticFaces: Bool - public init(isEnabled: Bool = true, maximumLights: Int = 4) { + public init(isEnabled: Bool = true, maximumLights: Int = 4, reusesStaticFaces: Bool = true) { self.isEnabled = isEnabled self.maximumLights = maximumLights + self.reusesStaticFaces = reusesStaticFaces + } + + private enum CodingKeys: CodingKey { case isEnabled, maximumLights, reusesStaticFaces } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = LocalShadowSettings3D() + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? defaults.isEnabled + maximumLights = try container.decodeIfPresent(Int.self, forKey: .maximumLights) ?? defaults.maximumLights + reusesStaticFaces = try container.decodeIfPresent(Bool.self, forKey: .reusesStaticFaces) ?? defaults.reusesStaticFaces } } @@ -210,6 +224,15 @@ struct LocalShadow3DViews: Resource { var color: RenderTexture? var depth: RenderTexture? var matrices = [Transform3D](repeating: .identity, count: 24) + /// Fingerprint of what each atlas face last drew, keyed by `slot * 6 + face`. + var faceDigests: [Int: UInt64] = [:] + /// Fingerprint of the face's view-projection matrix when its entry in `faceDigests` was taken. + var faceMatrixDigests: [Int: UInt64] = [:] + /// Fingerprint of the shadow casters when the faces were last prepared, or `nil` when it was not reusable. + var sceneDigest: UInt64? + /// Faces drawn and faces kept from an earlier frame by the last pass. + var renderedFaces = 0 + var reusedFaces = 0 } var entries: [Entity.ID: Entry] = [:] } diff --git a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift index 248ac2a99..2628356af 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift @@ -2,6 +2,24 @@ import AdaECS @_spi(Internal) import AdaRender import Math +/// One texture slot's UV transform: the first vector is the row-major 2x2 matrix, the second holds the offset. +public struct PBR3DTextureTransform: Sendable, Equatable { + public let matrix: Vector4 + public let offset: Vector4 + + init(_ transform: PBRTextureTransform?) { + let transform = transform ?? .identity + // glTF: uv' = T * R * S * uv with R = [[cos, sin], [-sin, cos]]. + let cosine = Math.cos(transform.rotation) + let sine = Math.sin(transform.rotation) + matrix = Vector4( + cosine * transform.scale.x, sine * transform.scale.y, + -sine * transform.scale.x, cosine * transform.scale.y + ) + offset = Vector4(transform.offset.x, transform.offset.y, 0, 0) + } +} + /// Per-draw material parameters; vertex instance data retains color and metal/rough factors. public struct PBR3DUniform: Sendable, Equatable { public let emissiveFactor: Vector4 @@ -9,6 +27,14 @@ public struct PBR3DUniform: Sendable, Equatable { public let flags: Vector4 public let coordinates: Vector4 public let emission: Vector4 + /// UV transforms for base color, metallic/roughness, normal, occlusion and emission, in that order. + public let transform0: PBR3DTextureTransform + public let transform1: PBR3DTextureTransform + public let transform2: PBR3DTextureTransform + public let transform3: PBR3DTextureTransform + public let transform4: PBR3DTextureTransform + /// RGB is the reflectance of a non-metal at normal incidence, from the index of refraction and `KHR_materials_specular`. + public let dielectric: Vector4 public init(material: PBRMaterial?, descriptor _: VertexDescriptor) { func coordinate(_ slot: Int) -> Int { @@ -17,11 +43,46 @@ public struct PBR3DUniform: Sendable, Equatable { } return material.textureCoordinates[slot] } + func transform(_ slot: Int) -> PBR3DTextureTransform { + PBR3DTextureTransform(material?.textureTransforms.indices.contains(slot) == true ? material?.textureTransforms[slot] : nil) + } emissiveFactor = Vector4(material?.emissiveFactor ?? .one, 0) properties = Vector4(material?.normalScale ?? 1, material?.occlusionStrength ?? 1, material?.alphaCutoff ?? 0.5, Float(material?.alphaMode.rawValue ?? 0)) flags = Vector4(material?.doubleSided ?? true ? 1 : 0, material?.occlusionTexture != nil ? 1 : 0, 0, 0) coordinates = Vector4(Float(coordinate(0)), Float(coordinate(1)), Float(coordinate(2)), Float(coordinate(3))) emission = Vector4(Float(coordinate(4)), material?.baseColorFactor.w ?? 1, 1, 0) + transform0 = transform(0) + transform1 = transform(1) + transform2 = transform(2) + transform3 = transform(3) + transform4 = transform(4) + dielectric = Vector4(Self.dielectricReflectance(material), 0) + } + + /// Reflectance at normal incidence of the dielectric layer: ((ior - 1) / (ior + 1))^2 tinted and scaled by the + /// specular parameters, capped at 1. The defaults give exactly 0.04. + static func dielectricReflectance(_ material: PBRMaterial?) -> Vector3 { + let ior = material.map { $0.ior.isFinite ? max($0.ior, 1) : 1.5 } ?? 1.5 + let base = ((ior - 1) / (ior + 1)) * ((ior - 1) / (ior + 1)) + let strength = material.map { $0.specularFactor.isFinite ? max($0.specularFactor, 0) : 1 } ?? 1 + let tint = material?.specularColorFactor ?? .one + func channel(_ value: Float) -> Float { + min(base * (value.isFinite ? max(value, 0) : 1), 1) * strength + } + return Vector3(channel(tint.x), channel(tint.y), channel(tint.z)) + } + + /// A bit-exact view of every field, so equal material values share one buffer even for -0 and NaN. + var bitPatterns: [SIMD4] { + func bits(_ vector: Vector4) -> SIMD4 { + SIMD4(vector.x.bitPattern, vector.y.bitPattern, vector.z.bitPattern, vector.w.bitPattern) + } + return [ + bits(emissiveFactor), bits(properties), bits(flags), bits(coordinates), bits(emission), + bits(transform0.matrix), bits(transform0.offset), bits(transform1.matrix), bits(transform1.offset), + bits(transform2.matrix), bits(transform2.offset), bits(transform3.matrix), bits(transform3.offset), + bits(transform4.matrix), bits(transform4.offset), bits(dielectric), + ] } } @@ -30,21 +91,10 @@ public struct PBR3DUniform: Sendable, Equatable { public struct PBR3DUniforms: Resource { public static let binding = 15 private struct Key: Hashable { - let emissive: SIMD4 - let properties: SIMD4 - let flags: SIMD4 - let coordinates: SIMD4 - let emission: SIMD4 + let words: [SIMD4] init(_ value: PBR3DUniform) { - func bits(_ vector: Vector4) -> SIMD4 { - SIMD4(vector.x.bitPattern, vector.y.bitPattern, vector.z.bitPattern, vector.w.bitPattern) - } - emissive = bits(value.emissiveFactor) - properties = bits(value.properties) - flags = bits(value.flags) - coordinates = bits(value.coordinates) - emission = bits(value.emission) + words = value.bitPatterns } } private struct Entry { let buffer: any UniformBuffer; var frame: Int } diff --git a/Sources/AdaCorePipelines/CorePipelines/Render3DLightStatistics.swift b/Sources/AdaCorePipelines/CorePipelines/Render3DLightStatistics.swift index e8453465c..f5ea1efde 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Render3DLightStatistics.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Render3DLightStatistics.swift @@ -15,6 +15,8 @@ public final class Render3DLightStatistics: Resource, Sendable { public var shadowFaces: Int public var atlasWidth: Int public var atlasHeight: Int + /// Shadow faces kept from an earlier frame instead of redrawn. + public var reusedShadowFaces: Int = 0 } private let state = Mutex<[String: Snapshot]>([:]) public init() {} @@ -30,7 +32,8 @@ public final class Render3DLightStatistics: Resource, Sendable { shadowLights: selected.count, shadowFaces: selected.reduce(0) { $0 + ($1.kind == .point ? 6 : 1) }, atlasWidth: entry.color?.size.width ?? 0, - atlasHeight: entry.color?.size.height ?? 0 + atlasHeight: entry.color?.size.height ?? 0, + reusedShadowFaces: entry.reusedFaces ) state.withLock { $0["\(view)"] = value } } diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift index 7cab675fb..51ed6afe5 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift @@ -1,3 +1,4 @@ +import AdaAssets import AdaECS @_spi(Internal) import AdaRender import AdaUtils @@ -17,6 +18,24 @@ struct LocalShadow3DPipeline: RenderPipelineConfigurator { } } +/// Resets one atlas tile to the far value, so a face can be redrawn while its neighbors keep their contents. +struct LocalShadow3DClear: Resource { + let pipeline: RenderPipeline + + init(device: RenderDevice) { + let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/shadow_clear_3d.glsl") + var descriptor = RenderPipelineDescriptor( + vertex: shader.asset.requiredShader(for: .vertex), + fragment: shader.asset.getShader(for: .fragment), + debugName: "Local Shadow Clear 3D", + backfaceCulling: false + ) + descriptor.depthStencilDescriptor = DepthStencilDescriptor(isDepthTestEnabled: false, isDepthWriteEnabled: false, depthCompareOperator: .always) + descriptor.colorAttachments = [.init(format: .rgba_16f)] + pipeline = device.createRenderPipeline(from: descriptor) + } +} + /// One atlas pass per camera covers a bounded set of point/spot shadow faces, including offscreen casters. public struct LocalShadow3DRenderNode: RenderNode { public static let name: RenderNodeLabel = "Main3D.LocalShadows" @@ -33,6 +52,7 @@ public struct LocalShadow3DRenderNode: RenderNode { @Res private var skinning @ResMut> private var pipelines @Res private var device + @Res private var clear @Res private var metrics public init() {} @@ -51,6 +71,7 @@ public struct LocalShadow3DRenderNode: RenderNode { _skinning.update(from: world) _pipelines.update(from: world) _device.update(from: world) + _clear.update(from: world) _metrics.update(from: world) var active: [Entity.ID] = [] cameras.forEach { _, _, _, source in active.append(source.entityId) } @@ -95,24 +116,31 @@ public struct LocalShadow3DRenderNode: RenderNode { samplerDescription: .init(minFilter: .nearest, magFilter: .nearest, mipFilter: .notMipmapped) ) entry.depth = RenderTexture(size: size, scaleFactor: 1, format: .depth_32f_stencil8, debugLabel: "Local Shadow Depth") + // A new atlas holds nothing worth keeping. + entry.faceDigests.removeAll() + entry.faceMatrixDigests.removeAll() + entry.sceneDigest = nil } guard let color = entry.color, let depth = entry.depth else { views.entries[source.entityId] = entry return } - let command = renderContext.commandQueue.makeCommandBuffer() - command.label = "Local Shadows 3D" - let pass = command.beginRenderPass( - .init( - label: "Local Shadows 3D", - colorAttachments: [.init(texture: color, operation: .init(loadAction: .clear, storeAction: .store), clearColor: .white)], - depthStencilAttachment: .init( - texture: depth, - depthOperation: .init(loadAction: .clear, storeAction: .dontCare), - stencilOperation: .init(loadAction: .clear, storeAction: .dontCare) - ) - ) - ) + + // Prepare every face and fingerprint it before encoding, so unchanged faces can be left alone. + struct Face { + var index: Int + var tile: Rect + var isClean: Bool + } + var faces: [Face] = [] + var digests: [Int: UInt64] = [:] + var matrixDigests: [Int: UInt64] = [:] + // With the casters unchanged since the faces were last prepared, a face whose matrix is also unchanged + // draws what it drew before, so its list needs no rebuilding at all. + let sceneDigest = settings.reusesStaticFaces + ? ShadowSceneDigest3D.compute(sources: candidates.meshes, usesLOD: environment.meshVisibility.lod) + : nil + let sceneUnchanged = sceneDigest != nil && sceneDigest == entry.sceneDigest for light in entry.lights { guard let slot = entry.allocator.slots[light.entity] else { continue } let matrices = LocalLighting3DMath.shadowProjections(light) @@ -120,8 +148,14 @@ public struct LocalShadow3DRenderNode: RenderNode { let index = slot * 6 + face entry.matrices[index] = matrices[face] let tile = Rect(x: Float(face * resolution), y: Float(slot * resolution), width: Float(resolution), height: Float(resolution)) - pass.setViewport(tile) - pass.setScissorRect(tile) + var matrixDigest = FrameDigest3D() + matrixDigest.combine(matrices[face]) + matrixDigests[index] = matrixDigest.value + if sceneUnchanged, let kept = entry.faceDigests[index], entry.faceMatrixDigests[index] == matrixDigest.value { + digests[index] = kept + faces.append(Face(index: index, tile: tile, isClean: true)) + continue + } // Light-volume culling is independent of the main camera list and its distance cuts. lists.prepare( sources: candidates.meshes, @@ -134,36 +168,81 @@ public struct LocalShadow3DRenderNode: RenderNode { device: device.renderDevice, materials: &_materials.wrappedValue ) - let buffers = lists.buffers(view: source.entityId, pass: 100 + index) - guard let instances = buffers.instances else { continue } - let constants = scratch.cache.write( - DirectionalShadowViewUniform(viewProjection: matrices[face], tileBounds: [tile.minX, tile.minY, tile.maxX, tile.maxY]), - view: source.entityId, - pass: index, - device: device.renderDevice - ) - pass.setVertexBuffer(constants, offset: 0, slot: GlobalBufferIndex.viewUniform) - for item in lists.items(view: source.entityId, pass: 100 + index) { - guard item.castShadows, let range = item.batchRange, (item.material as? PBRMaterial)?.alphaMode != .blend else { continue } - let part = item.mesh.models[item.modelIndex].parts[item.partIndex] - guard let material = materials.buffer(for: item.material), let palette = item.skinningBuffer ?? skinning.identityBuffer else { continue } - pass.setRenderPipelineState(pipelines.pipeline(for: part.vertexDescriptor, device: device.renderDevice)) - pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) - pass.setVertexBuffer(instances, offset: Int(range.lowerBound) * MemoryLayout.stride, slot: 3) - if let defaults = buffers.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } - let base = (item.material as? PBRMaterial)?.baseColorTexture ?? Texture2D.whiteTexture - pass.setResourceSet( - .init(bindings: [ - .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(material, offset: 0)), - .init(binding: 4, shaderStages: .fragment, resource: .texture(base)), - .init(binding: 7, shaderStages: .fragment, resource: .sampler(base.sampler)), - .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), - ]), - index: 0 - ) - pass.setIndexBuffer(part.indexBuffer, offset: 0) - pass.drawIndexed(indexCount: part.indexCount, indexBufferOffset: 0, instanceCount: Int(range.count)) + var digest: UInt64? + if settings.reusesStaticFaces, let content = lists.shadowDigest(view: source.entityId, pass: 100 + index) { + var combined = FrameDigest3D() + combined.combine(matrices[face]) + combined.combine(content) + digest = combined.value + digests[index] = combined.value } + faces.append(Face(index: index, tile: tile, isClean: digest != nil && entry.faceDigests[index] == digest)) + } + } + entry.sceneDigest = sceneDigest + entry.faceMatrixDigests = matrixDigests + entry.faceDigests = digests + entry.reusedFaces = faces.filter(\.isClean).count + entry.renderedFaces = faces.count - entry.reusedFaces + guard entry.renderedFaces > 0 else { + // Every face already holds what it would draw again. + statistics.record(view: source.entityId, scene: _lighting.wrappedValue, entry: entry) + views.entries[source.entityId] = entry + return + } + + let keepsContents = entry.reusedFaces > 0 + let command = renderContext.commandQueue.makeCommandBuffer() + command.label = "Local Shadows 3D" + let pass = command.beginRenderPass( + .init( + label: "Local Shadows 3D", + colorAttachments: [.init(texture: color, operation: .init(loadAction: keepsContents ? .load : .clear, storeAction: .store), clearColor: .white)], + depthStencilAttachment: .init( + texture: depth, + depthOperation: .init(loadAction: .clear, storeAction: .dontCare), + stencilOperation: .init(loadAction: .clear, storeAction: .dontCare) + ) + ) + ) + for face in faces where !face.isClean { + let index = face.index + let tile = face.tile + pass.setViewport(tile) + pass.setScissorRect(tile) + if keepsContents { + pass.setRenderPipelineState(clear.pipeline) + pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) + } + let buffers = lists.buffers(view: source.entityId, pass: 100 + index) + guard let instances = buffers.instances else { continue } + let constants = scratch.cache.write( + DirectionalShadowViewUniform(viewProjection: entry.matrices[index], tileBounds: [tile.minX, tile.minY, tile.maxX, tile.maxY]), + view: source.entityId, + pass: index, + device: device.renderDevice + ) + pass.setVertexBuffer(constants, offset: 0, slot: GlobalBufferIndex.viewUniform) + for item in lists.items(view: source.entityId, pass: 100 + index) { + guard item.castShadows, let range = item.batchRange, (item.material as? PBRMaterial)?.alphaMode != .blend else { continue } + let part = item.mesh.models[item.modelIndex].parts[item.partIndex] + guard let material = materials.buffer(for: item.material), let palette = item.skinningBuffer ?? skinning.identityBuffer else { continue } + pass.setRenderPipelineState(pipelines.pipeline(for: part.vertexDescriptor, device: device.renderDevice)) + pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) + pass.setVertexBuffer(instances, offset: Int(range.lowerBound) * MemoryLayout.stride, slot: 3) + if let defaults = buffers.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } + let base = (item.material as? PBRMaterial)?.baseColorTexture ?? Texture2D.whiteTexture + pass.setResourceSet( + .init(bindings: [ + .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(material, offset: 0)), + .init(binding: 4, shaderStages: .fragment, resource: .texture(base)), + .init(binding: 7, shaderStages: .fragment, resource: .sampler(base.sampler)), + .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), + ]), + index: 0 + ) + pass.setIndexBuffer(part.indexBuffer, offset: 0) + pass.drawIndexed(indexCount: part.indexCount, indexBufferOffset: 0, instanceCount: Int(range.count)) } } pass.endRenderPass() diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift index f00942088..5459549ef 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift @@ -42,6 +42,8 @@ public struct Main3DRenderNode: RenderNode { @ResMut private var localUniforms + @ResMut private var lightTiles + @ResMut private var lightingScratch @@ -71,6 +73,7 @@ public struct Main3DRenderNode: RenderNode { _lightingScratch.update(from: world) _viewUniforms.update(from: world) _localUniforms.update(from: world) + _lightTiles.update(from: world) _environments.update(from: world) _ibl.update(from: world) _renderDevice.update(from: world) @@ -79,6 +82,7 @@ public struct Main3DRenderNode: RenderNode { lightingScratch.cache.retainViews(activeViews) viewUniforms.cache.retainViews(activeViews) ibl.cache.retainViews(activeViews) + lightTiles.retainViews(activeViews) lists.retainViews(activeViews) gpuVisibility.retainViews(activeViews) } @@ -187,6 +191,29 @@ public struct Main3DRenderNode: RenderNode { } let localTexture = localShadows?.color ?? Texture2D.whiteTexture + // Tiles let the main pass skip local lights that cannot reach a pixel. Foveated rendering maps pixels + // through a rate map the CPU bounds do not model, so it keeps the flat loop. + let localLights = localShadows?.lights ?? [] + let tileLayout = LightTiles3DMath.layout(width: sceneColor.size.width, height: sceneColor.size.height) + let tilesActive = (environments.environments[source.entityId]?.tiledLocalLights ?? true) && camera.rasterizationRateMap == nil + let tileMasks = tilesActive + ? LightTiles3DMath.masks( + spheres: localLights.map { ((uniform.viewMatrix * Vector4($0.position, 1)).xyz, $0.range) }, + projection: drawUniform.projectionMatrix, + viewport: camera.viewport.rect, + layout: tileLayout + ) + : [] + let tileConstants = localUniforms.write(view: source.entityId, pass: 2, count: 6, device: renderDevice.renderDevice) { + $0[0] = drawUniform.projectionMatrix.x + $0[1] = drawUniform.projectionMatrix.y + $0[2] = drawUniform.projectionMatrix.z + $0[3] = drawUniform.projectionMatrix.w + $0[4] = [camera.viewport.rect.origin.x, camera.viewport.rect.origin.y, camera.viewport.rect.size.width, camera.viewport.rect.size.height] + $0[5] = [Float(tileLayout.tileSize), Float(tileLayout.columns), Float(tileLayout.rows), tilesActive ? 1 : 0] + } + let tileMaskBuffer = lightTiles.write(view: source.entityId, masks: tileMasks, device: renderDevice.renderDevice) + let commandBuffer = renderContext.commandQueue.makeCommandBuffer() commandBuffer.label = "Main 3d Render Pass" @@ -233,6 +260,8 @@ public struct Main3DRenderNode: RenderNode { renderPass.setFragmentBuffer(lightConstants, offset: 0, slot: 1) renderPass.setFragmentBuffer(localConstants, offset: 0, slot: 25) renderPass.setFragmentBuffer(localMatrices, offset: 0, slot: 28) + renderPass.setFragmentBuffer(tileConstants, offset: 0, slot: 27) + renderPass.setFragmentBuffer(tileMaskBuffer, offset: 0, slot: 29) renderPass.setResourceSet( RenderResourceSet(bindings: [ .init(binding: 26, shaderStages: .fragment, resource: .texture(localTexture)) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift index 8eb416332..53347c314 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift @@ -19,6 +19,9 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { @Res private var environments + @Res + private var lighting + @Res private var pipeline @@ -33,6 +36,7 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { @Res private var metrics @Res private var overlays @Res private var bloomViews + @Res private var volumetricViews @Res private var autoExposureViews public init(notifiesCompletion: Bool = true) { @@ -43,9 +47,11 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { _metrics.update(from: world) _overlays.update(from: world) _bloomViews.update(from: world) + _volumetricViews.update(from: world) _autoExposureViews.update(from: world) query.update(from: world) _environments.update(from: world) + _lighting.update(from: world) _pipeline.update(from: world) _scratch.update(from: world) _renderDevice.update(from: world) @@ -82,6 +88,12 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { let skybox = environment.skybox let reflection = environment.screenSpaceReflection let skyIntensity = max(0, skybox.intensity) + let fog = environment.fog.sanitized + let fogActive = environment.fog.changesImage + let shafts = fogActive && environment.fog.volumetricActive ? volumetricViews.output(view: source.entityId) : nil + let sun = lighting.directionalLight + let fogSunDirection = sun?.directionToLight.normalized ?? Vector3(0, 1, 0) + let fogSunRadiance = sun.map { $0.radiance * $0.intensity } ?? .zero let uniform = Environment3DUniform( projection: jitteredUniform.projectionMatrix, inverseProjection: jitteredUniform.projectionMatrix.inverse, @@ -138,7 +150,12 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { environment.postProcess.vignette.sanitized.intensity, environment.postProcess.vignette.sanitized.radius ), - vignette: Vector4(environment.postProcess.vignette.sanitized.smoothness, autoExposureTexture == nil ? 0 : 1, 0, 0) + vignette: Vector4(environment.postProcess.vignette.sanitized.smoothness, autoExposureTexture == nil ? 0 : 1, 0, 0), + fogColor: Vector4(fog.color.asVector.xyz, fogActive ? fog.density : 0), + fogShape: Vector4(fog.heightFalloff, fog.baseHeight, fog.startDistance, fog.maximumOpacity), + fogRange: Vector4(fog.skyInfluence, fog.sunExponent, shafts == nil ? 0 : 1, 0), + fogSun: Vector4(fogSunDirection, fogActive && shafts == nil ? fog.sunInscattering : 0), + fogSunColor: Vector4(fogSunRadiance, 0) ) let constants = scratch.cache.write(uniform, view: source.entityId, device: renderDevice.renderDevice) @@ -166,6 +183,7 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { .init(binding: 9, shaderStages: .fragment, resource: .texture(target.ambientOcclusion3DTexture ?? Texture2D.whiteTexture)), .init(binding: 11, shaderStages: .fragment, resource: .sampler(pipeline.nearest)), .init(binding: 12, shaderStages: .fragment, resource: .texture(bloomTexture ?? Texture2D.whiteTexture)), + .init(binding: 16, shaderStages: .fragment, resource: .texture(shafts ?? Texture2D.whiteTexture)), .init(binding: 13, shaderStages: .fragment, resource: .texture(gradingLUT?.texture ?? Texture2D.whiteTexture)), .init(binding: 14, shaderStages: .fragment, resource: .sampler(pipeline.sampler)), .init(binding: 15, shaderStages: .fragment, resource: .texture(autoExposureTexture ?? Texture2D.whiteTexture)), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift new file mode 100644 index 000000000..b6528a7d0 --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift @@ -0,0 +1,185 @@ +import AdaAssets +import AdaECS +@_spi(Internal) import AdaRender +import AdaUtils +import Math + +/// Marches the primary directional light through the fog medium at half resolution and stores the in-scattered light. +/// +/// Runs after the main pass and before the environment composite, which adds the result to the frame in linear HDR. +/// A camera without active volumetric fog, or one showing a full-screen debug view, records no GPU work and holds +/// no texture. +public struct VolumetricFog3DRenderNode: RenderNode { + public static let name: RenderNodeLabel = "Main3D.VolumetricFog" + + @Query private var views + @Res private var environments + @Res private var lighting + @ResMut private var volumetric + @Res private var device + @Res private var metrics + + public init() {} + + public func update(from world: World) { + views.update(from: world) + _environments.update(from: world) + _lighting.update(from: world) + _volumetric.update(from: world) + _device.update(from: world) + _metrics.update(from: world) + var activeViews: [Entity.ID] = [] + views.forEach { _, _, _, _, source in activeViews.append(source.entityId) } + volumetric.retainViews(activeViews) + } + + public func execute(context: inout Context, renderContext: RenderContext) async throws -> [RenderSlotValue] { + guard let view = context.viewEntity else { + return [] + } + let shadow = context.world.getResource(DirectionalShadow3D.self) + var submitted: CommandBuffer? + views.forEach { entity, camera, target, viewUniform, source in + guard entity == view else { + return + } + guard let environment = environments.environments[source.entityId], environment.fog.volumetricActive, + !environment.debugView.replacesFinalImage, let sun = lighting.directionalLight, + target.rendering3DUsesEnvironmentTargets, let position = target.viewPositionMetallic3DTexture + else { + volumetric.discard(view: source.entityId) + return + } + let fog = environment.fog.sanitized + let settings = fog.volumetric + let output = volumetric.output(view: source.entityId, fullSize: position.size, scaleFactor: position.scaleFactor) + let shadowsEnabled = shadow.map { $0.isEnabled && sun.castsShadows && $0.colorTexture != nil } ?? false + let atlas = shadow?.colorTexture?.size ?? SizeInt(width: 1024, height: 1024) + let jittered = temporalViewUniform(viewUniform, target: target) + let constants = volumetric.uniform.write( + VolumetricFog3DUniform( + inverseView: viewUniform.viewMatrix.inverse, + inverseProjection: jittered.projectionMatrix.inverse, + shadowViewProjection0: shadow?.viewProjection ?? .identity, + shadowViewProjection1: shadow?.viewProjection1 ?? .identity, + shadowViewProjection2: shadow?.viewProjection2 ?? .identity, + cascadeSplits: shadow?.cascadeSplits ?? [1000, 1000, 1000, 1], + shadowAtlas: Vector4( + 1 / Float(max(atlas.width, 1)), + 1 / Float(max(atlas.height, 1)), + max(0, sun.shadowBias), + shadowsEnabled ? 1 : 0 + ), + sunDirection: Vector4(sun.directionToLight.normalized, settings.anisotropy), + sunRadiance: Vector4(sun.radiance * (sun.intensity * settings.intensity), 0), + medium: Vector4(fog.density, fog.heightFalloff, fog.baseHeight, fog.startDistance), + march: Vector4(Float(settings.steps), settings.maxDistance, 0, 0) + ), + view: source.entityId, + device: device.renderDevice + ) + let command = renderContext.commandQueue.makeCommandBuffer() + command.label = "Volumetric Fog 3D" + let pass = command.beginRenderPass(RenderPassDescriptor( + label: "Volumetric Fog 3D", + colorAttachments: [.init(texture: output, operation: .init(loadAction: .clear, storeAction: .store), clearColor: .black)], + depthStencilAttachment: nil + )) + pass.setViewport(Rect(x: 0, y: 0, width: Float(output.size.width), height: Float(output.size.height))) + pass.setRenderPipelineState(volumetric.pipeline) + pass.setFragmentBuffer(constants, offset: 0, slot: 4) + pass.setResourceSet(RenderResourceSet(bindings: [ + .init(binding: 0, shaderStages: .fragment, resource: .texture(position)), + .init(binding: 1, shaderStages: .fragment, resource: .texture(shadow?.colorTexture ?? Texture2D.whiteTexture)), + .init(binding: 2, shaderStages: .fragment, resource: .sampler(volumetric.nearest)), + ]), index: 0) + pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) + pass.endRenderPass() + if let metrics { + command.addCompletedTimingHandler { metrics.record(pass: "volumetricFog", seconds: $0) } + } + submitted = command + } + if let submitted { + if let metrics { await metrics.commit(submitted) } else { submitted.commit() } + } + return [] + } +} + +struct VolumetricFog3DUniform: Sendable { + var inverseView: Transform3D + var inverseProjection: Transform3D + var shadowViewProjection0: Transform3D + var shadowViewProjection1: Transform3D + var shadowViewProjection2: Transform3D + var cascadeSplits: Vector4 + var shadowAtlas: Vector4 + var sunDirection: Vector4 + var sunRadiance: Vector4 + var medium: Vector4 + var march: Vector4 +} + +/// The light-shaft pipeline and a half-resolution result texture per camera, created only while the effect runs. +struct VolumetricFog3DViews: Resource { + let pipeline: RenderPipeline + let nearest: Sampler + var uniform = FrameUniformCache3D() + private var textures: [Entity.ID: RenderTexture] = [:] + private var retired: [RenderTexture] = [] + + init(device: RenderDevice) { + let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/volumetric_fog_3d.glsl") + var descriptor = RenderPipelineDescriptor( + vertex: shader.asset.requiredShader(for: .vertex), + fragment: shader.asset.getShader(for: .fragment), + debugName: "Volumetric Fog 3D", + backfaceCulling: false, + depthPixelFormat: .none + ) + descriptor.colorAttachments = [.init(format: .rgba_16f)] + pipeline = device.createRenderPipeline(from: descriptor) + nearest = device.createSampler(from: .init(minFilter: .nearest, magFilter: .nearest, mipFilter: .notMipmapped)) + } + + /// The light-shaft texture for `view` if the effect ran for it. + func output(view: Entity.ID) -> RenderTexture? { + textures[view] + } + + /// A texture at half the size of the scene targets, rebuilt when that size changes. + mutating func output(view: Entity.ID, fullSize: SizeInt, scaleFactor: Float) -> RenderTexture { + let size = SizeInt(width: max(1, fullSize.width / 2), height: max(1, fullSize.height / 2)) + if let existing = textures[view], existing.size == size { + return existing + } + if let replaced = textures[view] { + retire(replaced) + } + let texture = RenderTexture(size: size, scaleFactor: scaleFactor, format: .rgba_16f, debugLabel: "3D Volumetric Fog") + textures[view] = texture + return texture + } + + mutating func retainViews(_ views: [Entity.ID]) { + for view in textures.keys where !views.contains(view) { + discard(view: view) + } + uniform.retainViews(views) + } + + mutating func discard(view: Entity.ID) { + if let removed = textures.removeValue(forKey: view) { + retire(removed) + } + } + + private mutating func retire(_ texture: RenderTexture) { + retired.append(texture) + let limit = max(1, unsafe RenderEngine.configurations.maxFramesInFlight) + 1 + if retired.count > limit { + retired.removeFirst(retired.count - limit) + } + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift index 600f15c75..e17e2b326 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift @@ -59,6 +59,11 @@ struct Environment3DUniform: Sendable { var postProcess: Vector4 var grading: Vector4 var vignette: Vector4 + var fogColor: Vector4 + var fogShape: Vector4 + var fogRange: Vector4 + var fogSun: Vector4 + var fogSunColor: Vector4 } public struct ScreenSpaceReflectionScratch: Resource, Sendable { diff --git a/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift b/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift new file mode 100644 index 000000000..d7646418f --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift @@ -0,0 +1,35 @@ +@_spi(Internal) import AdaRender +import Math + +/// A fingerprint of every shadow caster in the extracted scene, used to skip rebuilding per-face draw lists when +/// nothing a shadow face could draw has changed. +enum ShadowSceneDigest3D { + /// The digest of the casters in `sources`, or `nil` when the scene cannot be summarized cheaply: a skinned caster + /// changes pose without any change visible here, and with level-of-detail on, which mesh a caster draws + /// depends on the camera. + static func compute(sources: [Mesh3DRenderSource], usesLOD: Bool) -> UInt64? { + var digest = FrameDigest3D() + for index in sources.indices where sources[index].castShadows { + if sources[index].skinningBuffer != nil || (usesLOD && !sources[index].alternatives.isEmpty) { + return nil + } + digest.combine(sources[index].transform) + digest.combine(UInt64(sources[index].forceVisible ? 1 : 0)) + for model in sources[index].mesh.models { + for part in model.parts { + digest.combine(UInt64(UInt(bitPattern: ObjectIdentifier(part.vertexBuffer)))) + digest.combine(UInt64(part.indexCount)) + digest.combine(UInt64(part.materialIndex)) + } + } + for material in sources[index].materials { + digest.combine(UInt64(UInt(bitPattern: ObjectIdentifier(material)))) + if let pbr = material as? PBRMaterial { + digest.combine(UInt64(truncatingIfNeeded: pbr.uniformRevision)) + digest.combine(UInt64(pbr.baseColorTexture.map { UInt(bitPattern: ObjectIdentifier($0)) } ?? 0)) + } + } + } + return digest.value + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift b/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift index 1b7da1924..401d9ed19 100644 --- a/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift +++ b/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift @@ -12,6 +12,32 @@ public struct Active3DInstanceBuffers: Resource { } /// Compact, frame-buffered draw lists per camera and per shadow cascade. +/// A cheap rolling hash used to tell whether a shadow face would draw the same thing as last frame. +struct FrameDigest3D: Sendable { + private(set) var value: UInt64 = 0xcbf2_9ce4_8422_2325 + + mutating func combine(_ word: UInt64) { + value = (value ^ word) &* 0x0000_0100_0000_01b3 + value ^= value >> 29 + } + + mutating func combine(_ float: Float) { combine(UInt64(float.bitPattern)) } + + mutating func combine(_ vector: Vector4) { + combine(vector.x) + combine(vector.y) + combine(vector.z) + combine(vector.w) + } + + mutating func combine(_ matrix: Transform3D) { + combine(matrix.x) + combine(matrix.y) + combine(matrix.z) + combine(matrix.w) + } +} + public struct VisibleMesh3DLists: Resource { private struct Key: Hashable { var view: Entity.ID @@ -24,6 +50,8 @@ public struct VisibleMesh3DLists: Resource { var count = Visibility3DCount() var records: [Record] = [] var order: [Int32] = [] + var digest = FrameDigest3D() + var isDynamic = false } private struct SelectionKey: Hashable { @@ -58,6 +86,8 @@ public struct VisibleMesh3DLists: Resource { var blended: Bool /// Batch order, assigned when the first record of this batch is appended. var rank: Int + /// Hash of the batch key, which names the mesh part, material parameters and skin of every instance in it. + var batchHash: UInt64 = 0 } /// A visible part, kept free of references so building, ordering and reading thousands of them costs no @@ -73,6 +103,8 @@ public struct VisibleMesh3DLists: Resource { } private var entries: [Key: Entry] = [:] + /// Passes prepared since creation, for tests that check which lists a frame rebuilt. + private(set) var preparedPassCount = 0 private var history: [SelectionKey: Int] = [:] public init() {} @@ -92,6 +124,7 @@ public struct VisibleMesh3DLists: Resource { device: RenderDevice, materials: inout PBR3DUniforms ) { + preparedPassCount &+= 1 let key = Key(view: view, pass: pass) var entry = entries.removeValue(forKey: key) ?? Entry() entry.items.removeAll(keepingCapacity: true) @@ -100,6 +133,8 @@ public struct VisibleMesh3DLists: Resource { entry.order.removeAll(keepingCapacity: true) entry.candidates.removeAll(keepingCapacity: true) entry.count = Visibility3DCount() + entry.digest = FrameDigest3D() + entry.isDynamic = false let cameraPosition = viewMatrix.inverse.origin let frustum = MeshVisibilityFrustum3D(viewProjection: cullingProjection) let isShadowPass = pass >= 0 @@ -200,6 +235,7 @@ public struct VisibleMesh3DLists: Resource { if !isShadowPass { materials.write(material: material, descriptor: descriptor, device: device) } template = Int32(templates.count) templateIndex[batchKey] = template + let templateHash = UInt64(bitPattern: Int64(batchKey.hashValue)) templates.append(Self.makeTemplate( material: material, descriptor: descriptor, @@ -207,6 +243,7 @@ public struct VisibleMesh3DLists: Resource { blended: pbr?.alphaMode == .blend, shadowOnly: isShadowPass )) + templates[Int(template)].batchHash = templateHash } if templates[Int(template)].rank < 0 { templates[Int(template)].rank = rankCount @@ -249,6 +286,14 @@ public struct VisibleMesh3DLists: Resource { let template = templates[Int(record.template)] var shadowFlags = template.shadowFlags shadowFlags.w = 1 - record.fade + if isShadowPass { + // A skinned pose changes without any CPU-visible identity change, so such a face is never reusable. + if sources[sourceIndex].skinningBuffer != nil { entry.isDynamic = true } + entry.digest.combine(template.batchHash) + entry.digest.combine(sources[sourceIndex].transform) + entry.digest.combine((sources[sourceIndex].materials[Int(record.material)] as? PBRMaterial)?.baseColorFactor ?? .one) + entry.digest.combine(shadowFlags.w) + } let instanceIndex = entry.buffers.append(Flat3DInstanceData( modelMatrix: sources[sourceIndex].transform, // Shadow batches can mix materials that only differ in colour, so that comes from the record itself. @@ -298,6 +343,18 @@ public struct VisibleMesh3DLists: Resource { entries[key] = entry } + /// Fingerprint of everything a shadow pass would draw (batches, transforms, alpha inputs), or `nil` when the pass + /// contains skinned meshes whose pose changes without any visible change here. Equal fingerprints mean an + /// identical face. Meshes edited in place keep their buffer identity and are not noticed. + func shadowDigest(view: Entity.ID, pass: Int) -> UInt64? { + guard let entry = entries[Key(view: view, pass: pass)], !entry.isDynamic else { + return nil + } + var digest = entry.digest + digest.combine(UInt64(entry.items.count)) + return digest.value + } + public func gpuCandidates(view: Entity.ID, pass: Int) -> [GPUVisibilityCandidate] { entries[Key(view: view, pass: pass)]?.candidates ?? [] } public func items(view: Entity.ID, pass: Int) -> [Opaque3DRenderItem] { entries[Key(view: view, pass: pass)]?.items ?? [] } diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index 15a05a6b9..f4cdacbf0 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -9,6 +9,22 @@ import AdaAssets import AdaUtils import Math +/// Scales, then rotates, then offsets the UV coordinates a PBR texture is sampled with (glTF `KHR_texture_transform`). +public struct PBRTextureTransform: Sendable, Equatable { + public var offset: Vector2 + /// Counter-clockwise rotation in radians. + public var rotation: Float + public var scale: Vector2 + + public static let identity = PBRTextureTransform() + + public init(offset: Vector2 = .zero, rotation: Float = 0, scale: Vector2 = .one) { + self.offset = offset + self.rotation = rotation + self.scale = scale + } +} + /// A material that uses Physically Based Rendering (PBR) to define its appearance. public class PBRMaterial: Material, @unchecked Sendable { public var baseColorFactor: Vector4 = .one { didSet { uniformRevision &+= 1 } } @@ -32,6 +48,14 @@ public class PBRMaterial: Material, @unchecked Sendable { /// A negative value emits everywhere. Non-negative values fade emission /// out as direct illumination rises above this threshold. public var emissiveLightThreshold: Float = -1 + /// UV transforms for base color, metallic/roughness, normal, occlusion, and emission. + public var textureTransforms: [PBRTextureTransform] = Array(repeating: .identity, count: 5) { didSet { uniformRevision &+= 1 } } + /// Index of refraction of the dielectric layer. The default 1.5 gives the usual 4% reflectance at normal incidence. + public var ior: Float = 1.5 { didSet { uniformRevision &+= 1 } } + /// Strength of the dielectric specular reflection (`KHR_materials_specular`). + public var specularFactor: Float = 1 { didSet { uniformRevision &+= 1 } } + /// Tint of the dielectric specular reflection (`KHR_materials_specular`). + public var specularColorFactor: Vector3 = .one { didSet { uniformRevision &+= 1 } } /// Changes whenever parameters in the shared PBR uniform snapshot are edited. @_spi(Internal) public private(set) var uniformRevision: UInt64 = 0 diff --git a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift index 2d1c6b836..5e2af1dd7 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift @@ -59,6 +59,12 @@ extension ModelAsset3D { material.alphaCutoff = source.alphaCutoff material.doubleSided = source.doubleSided material.textureCoordinates = source.textureCoordinates + material.textureTransforms = source.textureTransforms.map { + PBRTextureTransform(offset: $0.offset, rotation: $0.rotation, scale: $0.scale) + } + material.ior = source.ior + material.specularFactor = source.specularFactor + material.specularColorFactor = source.specularColorFactor switch source.alphaMode { case .opaque: material.alphaMode = .opaque case .mask: material.alphaMode = .mask diff --git a/Sources/AdaScene/3D/ModelAsset3D.swift b/Sources/AdaScene/3D/ModelAsset3D.swift index 393a51115..8e62d5d85 100644 --- a/Sources/AdaScene/3D/ModelAsset3D.swift +++ b/Sources/AdaScene/3D/ModelAsset3D.swift @@ -54,6 +54,9 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { public let skins: [GLTFImportResult.Skin] public let animationRig: SkeletalRig? public let animationClips: [SkeletalAnimationClip] + /// Content of the source file the importer could not honor, such as glTF extensions it does not implement. + /// Empty for formats that report nothing. The model still loads and renders without that content. + public private(set) var importWarnings: [String] = [] public var assetMetaInfo: AssetMetaInfo? @@ -138,6 +141,7 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { let loader = GLTFLoaderResolver.shared.getLoader() let result = try await loader.load(url: assetDecoder.assetMeta.filePath) + importWarnings = result.warnings guard result.skins.allSatisfy({ $0.joints.count <= Skinning3DUniforms.maximumJoints }) else { throw AssetError.message("A skinned mesh supports at most \(Skinning3DUniforms.maximumJoints) joints.") diff --git a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift new file mode 100644 index 000000000..d32016364 --- /dev/null +++ b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift @@ -0,0 +1,112 @@ +import AdaAssets +import Foundation +import Math +import Testing + +@Suite +struct GLTFMaterialExtensionTests { + private func load(material: [String: Any], used: [String]? = nil, required: [String]? = nil) throws -> GLTFImportResult { + var document: [String: Any] = [ + "asset": ["version": "2.0"], + "images": [["uri": "data:application/octet-stream;base64,AQID"]], + "textures": [["source": 0]], + "materials": [material], + ] + if let used { document["extensionsUsed"] = used } + if let required { document["extensionsRequired"] = required } + return try NativeGLTFLoader().load(data: JSONSerialization.data(withJSONObject: document)) + } + + private func transform(_ values: [String: Any]) -> [String: Any] { + ["extensions": ["KHR_texture_transform": values]] + } + + @Test + func texturesWithoutATransformKeepTheIdentity() throws { + let material = try #require(load(material: ["pbrMetallicRoughness": ["baseColorTexture": ["index": 0]]]).materials.first) + #expect(material.textureTransforms == Array(repeating: .identity, count: 5)) + #expect(material.ior == 1.5 && material.specularFactor == 1 && material.specularColorFactor == .one) + } + + @Test + func importsTextureTransformsPerSlot() throws { + let base = ["index": 0].merging(transform(["offset": [0.25, 0.5], "rotation": 1.5, "scale": [2, 3]])) { $1 } + let normal = ["index": 0].merging(transform(["scale": [4, 4]])) { $1 } + let material = try #require(load(material: [ + "pbrMetallicRoughness": ["baseColorTexture": base], + "normalTexture": normal, + ], used: ["KHR_texture_transform"]).materials.first) + #expect(material.textureTransforms[0] == .init(offset: Vector2(0.25, 0.5), rotation: 1.5, scale: Vector2(2, 3))) + #expect(material.textureTransforms[2] == .init(offset: .zero, rotation: 0, scale: Vector2(4, 4))) + #expect(material.textureTransforms[1] == .identity && material.textureTransforms[4] == .identity) + } + + @Test + func aTransformCanSelectAnotherUVSet() throws { + let texture = ["index": 0, "texCoord": 0].merging(transform(["texCoord": 1])) { $1 } + let material = try #require(load(material: ["emissiveTexture": texture]).materials.first) + #expect(material.textureCoordinates == [0, 0, 0, 0, 1]) + } + + private func object(_ json: String) throws -> [String: Any] { + try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + } + + @Test(arguments: [#"{"offset":[1]}"#, #"{"scale":[1,2,3]}"#, #"{"texCoord":5}"#]) + func rejectsMalformedTransforms(json: String) throws { + let values = try object(json) + let texture = ["index": 0].merging(transform(values)) { $1 } + #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { + try load(material: ["pbrMetallicRoughness": ["baseColorTexture": texture]]) + } + } + + @Test + func importsIORAndSpecular() throws { + let material = try #require(load(material: ["extensions": [ + "KHR_materials_ior": ["ior": 1.33], + "KHR_materials_specular": ["specularFactor": 0.5, "specularColorFactor": [1, 0.5, 0.25]], + ]], used: ["KHR_materials_ior", "KHR_materials_specular"]).materials.first) + #expect(material.ior == 1.33 && material.specularFactor == 0.5) + #expect(material.specularColorFactor == Vector3(1, 0.5, 0.25)) + } + + @Test(arguments: [ + #"{"KHR_materials_ior":{"ior":0.5}}"#, + #"{"KHR_materials_ior":{"ior":500}}"#, + #"{"KHR_materials_specular":{"specularFactor":-1}}"#, + #"{"KHR_materials_specular":{"specularColorFactor":[1,1]}}"#, + ]) + func rejectsOutOfRangeReflectance(json: String) throws { + let extensions = try object(json) + #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { try load(material: ["extensions": extensions]) } + } + + @Test + func unsupportedUsedExtensionsLoadWithAWarning() throws { + let result = try load(material: [:], used: ["KHR_materials_clearcoat", "KHR_texture_transform"]) + #expect(result.warnings.count == 1) + #expect(result.warnings.first?.contains("KHR_materials_clearcoat") == true) + } + + @Test + func unsupportedRequiredExtensionsStillFail() { + #expect(throws: NativeGLTFLoader.GLTFError.unsupportedRequiredExtension("KHR_materials_clearcoat")) { + try load(material: [:], used: ["KHR_materials_clearcoat"], required: ["KHR_materials_clearcoat"]) + } + } + + @Test + func specularTexturesAreReportedAsSkipped() throws { + let result = try load(material: ["extensions": ["KHR_materials_specular": ["specularTexture": ["index": 0]]]]) + #expect(result.warnings.contains { $0.contains("Material 0") && $0.contains("texture") }) + } + + @Test + func theSupportedListNamesEveryImplementedExtension() { + let supported = NativeGLTFLoader.supportedExtensions + for name in ["KHR_texture_transform", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_emissive_strength", "KHR_mesh_quantization"] { + #expect(supported.contains(name)) + } + } +} diff --git a/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift b/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift new file mode 100644 index 000000000..45876f1e7 --- /dev/null +++ b/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift @@ -0,0 +1,35 @@ +import AdaAssets +@_spi(Internal) @testable import AdaRender +@testable import AdaScene +import Math +import Testing + +@Suite +struct GLTFMaterialExtensionMappingTests { + @Test + func importedExtensionValuesReachThePBRMaterial() throws { + var transforms = Array(repeating: GLTFImportResult.TextureTransform.identity, count: 5) + transforms[0] = .init(offset: Vector2(0.1, 0.2), rotation: 0.5, scale: Vector2(2, 3)) + transforms[2] = .init(scale: Vector2(4, 4)) + let source = GLTFImportResult.Material( + name: nil, + baseColorFactor: .one, + baseColorTextureIndex: nil, + metallicFactor: 0, + roughnessFactor: 0.4, + metallicRoughnessTextureIndex: nil, + normalTextureIndex: nil, + textureTransforms: transforms, + ior: 1.33, + specularFactor: 0.6, + specularColorFactor: Vector3(1, 0.8, 0.6) + ) + let result = GLTFImportResult(nodes: [], meshes: [], materials: [source], textures: [], images: [], scenes: [], defaultScene: nil) + let material = try #require(ModelAsset3D.makeMaterials(result).first as? PBRMaterial) + #expect(material.textureTransforms[0] == PBRTextureTransform(offset: Vector2(0.1, 0.2), rotation: 0.5, scale: Vector2(2, 3))) + #expect(material.textureTransforms[2] == PBRTextureTransform(scale: Vector2(4, 4))) + #expect(material.textureTransforms[1] == .identity) + #expect(material.ior == 1.33 && material.specularFactor == 0.6) + #expect(material.specularColorFactor == Vector3(1, 0.8, 0.6)) + } +} diff --git a/Tests/AdaEngineTests/GoldenFrames/metal/pbr-spheres-fog.png b/Tests/AdaEngineTests/GoldenFrames/metal/pbr-spheres-fog.png new file mode 100644 index 0000000000000000000000000000000000000000..7c60f3f52c19af7d86b9c4960c747f5eb3bad5d1 GIT binary patch literal 82180 zcmZU5WmsFw_BVtScPQ>!iWMpD?i6SX#hp^zU4m2Gp=fbRarfZv?kQRbZpHhCbI&=q z_y2y#vy<#KGt1X+&Fm!njfyM=DhVna92|zcoRm5o9KatA4jzJx2>a&s=zh zRY(8o^Sj-Tsj&Ef3zDM3kpupHVM7F!Mi5(Ka{u=$*d2dlQ$Q-A1Whb59UxrnuM1&_ z7=u3{&kz2UKZkR|k68UN8!iY^xSiL3%oPp}rjA8{1S`8^^8UH5 z65Kh-znsC?fOKIG08hFYghJE*%Sbqk4-brg`GT=$Zv(V1Ewc0lr8XoheOn{PkP4t(@}D0<6QpKi$#c7`t{8TC%iLVwaOp7 zL(s8_6mK^sY?~%s5*af86rRRj9ob!lw_ti3#n5@k)E?E@GU1Q=U?ZHco8_0nOt8D3 zJ2{CX2k8DB4*r1?*0dzhw#VSl@y41ep)*Wr>QCVhut2E^HcR_cz*jsIJQ*lPQjB|l z+PgkREZxs0FE|MoT;I9yRS8kYIbQV-#tnsm#?f{9LB0C%aD6j24?DRQk&TTvGykJ; zWH?Z&;vq@g4vJxVbJ={-&smw3OMgz^KQ&VnAU2YAHNOk}kN{V&+<#%y##XP-VZ5w; z_9wf#0Dz5e?J~!>g@Gb2cp>v=t8YV|MI@1qg211Cin41DGK8d2Ym zZ<0$Ul9m2{bW=iu?UcD1Wpv5%X%zSh^Q(<>+t+%1o0$dU|IrZ{Gr&frCf(OInaPG4 z-a7QTV66xcHl4MOqR5}CO44GVC$Cn!`nha_lED zZG9Pi_Mc85fZU^NLjKz!yW5NnNePaYj1>+lzWV=9Iw6@e%IL`h9gJq=;*SSb26F&v zAGxK*iK=W^Z~kldMr)w)SfwysH18cB%rhj`0~*%w>;A81>>rV9n>t-(yh(`rQ0V** zouv%tvzz}v?sfykT7TPFye&$T&M*WJ6_q~oX|uFxTPm*V&Hp20oUuP1pQiu17pe}z zQ-xc$j7+G0Ol-*F__S>NhYQ&2BG>*p9rbeaO#`RV`#XGI9@PHr7?$;aj8^Ff-0<-f zEX)4A`WT4^zQPSlXnHJ|^6V?Ni5UE^mcdszACG&}8|*#Y&e0U$Hn%5LE^brQp^Gxn zWq(E?lzL*#Za!i|-=m>@0yx1ASFwy{^f1igS+CYs_x-;@7;*qkeVZ2?zwsOo;grDa zKoV@r9)HQ!Jy#l~{Y8=HA8%>22QGQ-?AUMVx#5JQ!Ldmc{oIhQeWYK4F<4MS_iqwI z6VGX5x8*r#EiDf)VDK-^kzT$6==?sj5ndi(c4H;~mylK_r|sBgZno9R_K7e(M|S-R zp!@sG7DhQhSmP?S@Yxex@Ub{!^9$b+&_M0 zjRCr9uloRLh1!Iy8y_pd5pLEzv+;tj=n&E#=Q$$~NT>dH{Xv*j3I?O~x?4+Kj27cw z;epBF1u5#=!1D$oWfr6!tym@U|74B#vM^r1Q&4+u=`rV4*$Lo*eP~7I^;_WP4D^M^ zd2V&jnf|Q_0>~FfOyo7sr95P{;lF35n*$o1!ZdAi^r+HVJLg)W+CLpn{T+i4A($-* z(_=V~`O-}qTaeS>jxd*5rELi(%oS@dZS(9(rimH;V*tnij1(!|jraHkjI`s46bs5) zH^WvrnruJbVq>I8KK| zhx{$t0zBYQq}>D0gK>eZd-F4_;5m%;Cwoq_&5YIuWVtPOB@1L3{jIY1QaKH>6-2ov zSuNg3i8Z)p0QdLP{5+|y)OPK9wKz)zeV*}We+T3!_QzlAqq$9PM;p;S1v9W>HKc^q zx2W?o-G)i{Sf4r2mhSJ0P>Bs%TWg*$isy9Om4B7lq{iTHA<{V=I&X`dZYS7=Y{NW# zmy-OKWEs4GHen}0+ObSGLB1@_L?yU!E0G4iFKZ!HH&-(e6IPZH2!n6_A|)dSw6)m_ zW{!}%z)1a`Nd2N4_`25ms&9Da{)hAc1GGlbklE){w1OF(LQIbb9?tm18r|FgZ_~aS zCH=FmzPHn~_%FHUWDKp>wFMzy4z~_?vO()eSeYl4OoiXU`DycZd6C3R&9iLu7Zd$o zELdZK*7R%Jem0fbU=2rdKS2|dfqd^_cS@62@@lGhpA`Ie`Q(wPWZH1^IgTILq0b$Qnlttvc>R@ErE zItE62AXC}0JXJklBD}6&Nx*;nyy?2{$mK~dg$eCs$D@ntkSBYGK9?dH;!$}Ma(T1LqGM4>4 zL%BgZ06eHIw3}1+uaZ-c8)ugLNcC*m>cI&qZIr4c0$lnD;6%meCs48MJB{N_M zk(mk>bEt*|mNb==z`~UmG3*!%?_vsbsDXz?&Y(RZ?Szu~#Ys#m!DLu6q=i|U51{lJ4|FbOm_+JcsJ+Xv zJ1kQ*^ty`W{XQ)DL*C-RCVFeZc9)54O%77V4OF6N5FtlHu;tW5LnwN39yTCM`Rt~- zPO0HwTbu=H2}d^hZMMqALu>O3STykY!Q>SB%|C|20ATzq#-PF|;IMf3bi){UU*N*v zFDoR!Soe22`0K(W4#sQe=_|{kF>q^m^}k@?ly+hm>e+TA0&ktkMSUQiQvK&h%m}gc z8s$S}Uf5l)PA(>EHHH5{F{?Ba!-I2{4(fmfv=yirL$GkZ`jZ;|ibL21?1_AFR(45) z(Ke?77iyGfC76a{gjJA8|1X6Y&4g^uXu<9Kbj5}*aP_Nu7&Hl81O;3Ft&Hjugcg5*oHe^!72d|TKWIg*hM$FTdctVC(pS8o)aV&!i(GH_uq&`HTh z^fR#XSw4~$LolntpJkncdScihy2P)FT{aD_4^vhyJZ-Qigg!&zrvHD$FB`K>j^oC6cjSJSWoubf9$h{J--IOXQ@`W;_EZBDz{32BM9GcsA-%p<6ga?Na@_789(>J!ssk zbeZ>d?%{#6rk|NhqIApaZY&P|!?7U~pv~)6y-0&=!7z5y4*pkwx%z4BB3h2o+`~lN10)bo=&THV3q#bPi!sw?S8I$5a)z>vb?D(-|nE;E-Clo=S75?DI z1XPzH_*n^BP;R7f_`mDHq~Ggpu2SiFbq*ST~~3TnpjM?qRa8n~+zI9Gd!>cqts;TM$4 z@(8rxT(@`mEPvPKAnc#3(<;Sx>28+;@O;()K14gLTC~w1&o^-OXu&22pXELN;{;$3 zHc^Wzd9h%q)m4u_-x#ox*GJfpoeyol6Mlh>D1Pl-!artWrGDkQzNmgZjp~GGn4tn# zXyNW-fCSX!1m(MZ{byyvmjIJraV3uy7H)<98Lj|6*Q84ZXh4DlcqR~dh8Fq^+w`v@ z523*!YGK!vqf7dr#m%J$88@hFB^$yE{bT$H)5 z5wQ|c)Q$uGcKmeO%8$pZLbqvBa50_}1dR-_@J35S|0;6asD5y31Jh{&YKc0Ga3i?qxGZ*#)Vj^fjdFVa8rZHWO;eYfF_LSHoG ztu1T#o?*;56o}dQZ&#TPYuX_eQ$fB89J9q47PK^t2K29+b9Ms1`-Pjij`dX5(o|1= zgr=ZCe(XZ-y(&M_4x?`t@A^&7*yL@dh&;6Pxsf_0a=3B9*Ec_rDOHfYR{3w#@p zS(f40g(VkQZ;@i!t#7f`IuBq_1qI*%vJ23^JtBgYC^vNbkacVj6X{2WaMFN`qbDrG z3DCGIObRTNq^T5rD#w&_i&N#;sns|Z@r}$ET(&g!zQ{_88e?`d{JCh75Q6gaR$l#u z?*<$8Mbg(K~9jB^g2S1Ew5k>#RSku>FR3m?+@5|uq$#(>5L%TUc!=EA$8@D zb~*t^87`J>bhq@`M(M+d1S>o#FHIJb8SfsXJE%siQBfYS0%Ct|l~TAT$q zkREG#9WGdUlL-28ZJyw3a8wFy1IA_Ux?GCPL{ zuDa3Uu55LS`kH(uWk>^vAXwfOG)oyqz_3`@@<#^3b8dV$VXSG-xc5)Gg$EQ>M#j*<>js_DV4wR|BR9>^17t&B zYewF*b{hBAXn8@JdF&E@7T5XcD!EqE!9dMf(s5U2_d!Cgbdgc?Vb6rp_=+8e4+ZG` zkSS%M`EyI2(LbWE>0=isziyE3KCCi9Vv&XJF1P}vqO(V9F_hKY7o)6Z_Bi+v3<3$=(gOugu-r8m z%o}&@VQ=n6-y6Z7rTzB#jn&}2tvl`uToLlbbBwCH$MbQ}7oQD{?6aS+a3%~F`(QxG ze|sZtFdD(86M$xV&}ePyc=4n)W?4vQU%WHj3C zXKmP7Zy5lD!EjmkNx!@$%|o}W+kr+@mWENP(B;YSXIn|D;x~M0jCHr?K;*ea+u(iR7T~0aV^PmS=Pq5?LPKyA*Bxv2Q$FN zTH{?@?ZS${W93?Lf$kg9&Xlomuo@M3i+$%~T-HSQ$F)KiycHxAdgC8XfoVV$3AQKz zz(W+(FymF40f7ISEilP7`qDl>|M2^=3ncDAa=Kf$Y5qP2U8@}5$ zC6CM0^odDT6W5akY%!YmL;g%t6gaF!6KzCn!$tnlJ*Trx#}w-s8ImzuD26LMOVrzC zt*}gF32Sp43G_UswdG4;%>s`5)t+7Xs{Hr3UaLVC?QWd*=XoZ=i%aAx{Yf}lLLJPZ zilI_DfpG6{yg!@)u~j0XZ~U3$frT{wQFi+rug#a}0sSFh$hjA+dFu$SptL7a^<)a5 z^xYMNA}NgrfM%B(v5wmTcZ4GNhML)vyyZj)49d+MIHlyncDxkTZY-71sv9@C0xT#KIh-n4~kN7 zykA^m6nmKab#WHB7Fh_ZDf{!AiFxd-^OQV0K0*XBSOh95eQDJVLW>(^9?p8 z(@?AVheJ#nSaghGFE~pdd^gcS@!mjz;G-8R8QSV-#44qC(^eibyA18~-4~BnA*BwA z+{V&K>n1-+M{szuZqY5&SJc$q#;JsZRdB%HnhqS{Q6PyZ`mWSlu6)mWYx#wNm>y>A zIxa<_J3$mWYZkM{NN}cj_yfHnQ9QTO%^X%*TITXV6>?B(bjqk9ga0sguJ3-Z^T6_6 zJ}6&}#-1Blx93xw_*!g7%beG+<~nMhv(l=;`wW2~a(EBWrxnZIooOMQ1q^_oHhat7 zftu_n(7tquPYGx%_nz}!4(YE%%w*vJ9;LB$NxU}**D(8DZPIsVe-6)8uIS(9^VGf# z7k#MN?n7!=5T-z)Gm4 zZ6>PbUcrDO-Av!U(dd1C@dp+o`*m(sPssXPOpXRwTPtB}fuX~w7qoh800(KdycqI= zKw!@1CG-t(u+6MhBbz?;C$VMr26Y=GfAJ&#GAmbywMW5l7 ztu|Fff{Xm}gi$c%834QC;JM^wluoA$RX8^gJ%i_dRm2sSIgLFTG9<(gwcjD9&XfIR zefG_{>Tuz-&`q~acQxLPey!*IbD@ba!g`(5MaJJO9XHUArat<}*Gpg2v*!njqIPKT zz?Kumvx#W^Ht)mJ>;Ka}eUZzQ>DqrFkFLlDepXIEF0ancDKMHW@{wHRYglgZ?O#?_ zrm18Dw2KtZKYu>)@-C6EU6l;fL&+<^l(U@6?3&`ylZ?<>5(yeX#ByY zO63O`pl#ST&YDdOHJSkAjduO9$oxEdQNKeyr-`&rVa+lFmI5`fuP-b_q-%Dwd@w~Z zUPx<}cDz2|c&`(4_?fpoENQ~--V^PE0WM%$y)j>WS4$SQfoG5ZW%%v)7V+<{dCf}J zPBYL(NX{4o@gdUz(C0lQw?n%RB0i|17p-gV?4wqE4JkagWXJW`0}cKS z!CS%ooguYH4nHQgPuR)e=|I=tvn#RzNb#NTFdYusumcaqId7ONDbRkLx2gEBm<;%4 z@BnUrC$+oBES@nK_6buhdp5Z2K6oG>+Ba*sk8|g@Tje*yWW(jBog_4Q@v4hDqjwbJ z^tHU;dtAf}6CuH;ZNnC(wpMr>6Iy-Oz&4?`PCcPwU_9~KhYY$v{p*BRZ4tG)_X>~< z`RqI%eZqx4W%UH)3}M)bch&>`E?f~+Wdy0jv9E9eJM2^$c1(*N;3q;C1Zm6g>S~Cn z+gmWpMAqeeIv+nf*j;e@3@*3RO*1@lzKv$Pu43?PX&;NRrKy$-2d6!4y{0LdVs4&c z^VO5uG34JhA;#FO{o>lUriZyneH3g{ds&8ilO8|_+u&p%6gcHSfZ=dHdEl9ap^D>D z^)$?{|J~sG9s`4Kx|A&!_&^GeAL%venrEcuj=Db@Mz%Pxvw*XrE(Jp1jq#gmQZO(gXnGNLE*?}C8IRasnkIlt|cJvZ-@b9{q*8394PytTXYEEMOA&f zIqAY`Ok!i>7&WBb=JDF=7p1Q=Hf>rr^~ss4FN+4FU>;fZlsjb0IaBeVhK8_nc6Rn) z-$2;6QN?1IF$NsfalaI^^=&vFIg-}?Tvm15cD1!r>UaoOcs-~R2L^*!sWW7_ClS4f?U;pZ+NX|uC{&+r`U;cdQp?(6rR?V!4z}l!}d-k zd`Rqb-8eh>_66h@7ZV+~pvc3fTf6AFC~;_Z=no+W8S62zh>2b0O96w}P9rK>tTcw_ zF+N*)l|$9EK;K6!(L`Oncozn**FUEDJBPGIV%(v&mA{OSder7C#x@_;hqvryHQafO zxZs7-OIFszj{JU;)`Z&WaqLNUg)(|SI;&c>fKK|1NO8V2U<}B;fb9sOd|LDUIDc41 z7vTMxrU*&m;oR%7(JHxX5BdH;a6N4mRX%&`G&MPGUF_4@uG6<^4O_M;d`oT=gE=#X zl0Z3^nJ#$C!GpC>QI&UDJd%*RaoQhVVh7JZ^wcocOTfEi(~hvlyeDuUOCl;UxafVI z1Mi*L@jy$7H_nnyb+Tz^)pg|LCOJo2m6BtI8MLDv#4?8Av0!BTO?bCxwiAoTamuCa zJe)C@ciSB*>Gy&H_RI^+$^4&3L==|$onSC{H^d$+I^dC_{#@$=)CQ1|61#GpRxVw{ zD&lBlIDGTmMzmn?kJaLIXRU}o4LZqQdl&@iac&~FUay>U4Xz(rX49PjTGp^*ulJO@ zJl0s~@OLciHz!dv_%8+AY9pM;oPXjNCrW5%p(~WSiC#?7s2KAD)_sqVYDlq;PMP8u zpwyONU3uh8G*vQZeLbF3#icK6mcJTq)J0={IAN^ClrAnmLh?3}%uoUA4|BiFHI2v5auD_+p65CmOz%S-OM4LwMPl{R11U)tR30^^K&}+}2TM!8dL23k(7N#@(Zcb=7I<=}1?I}#ih67{<7Ly*5e=~2ZT#I@f9 zgBTl&K^2TXL(mLq5}pi`&IwDfyub*vd)ybY+FPcAtBKN9xy&4RT=5l}^=mN(9Jn2* z-)}v6k|WZWKTvRXlrIF31d3AmGqa0JIjJh3nifWUMX}O4d87S>$&Vzm_~xSDe=zos z>eRp<`q=uRXyBD)kh?)x0_4#15)d_eNF!@Zu0wW~&f%O0C>eBKW3F(m&st?)kTr0h z%&N+qYi@@iRD`x~I?;z4wo9{2=)WBVo&*%$jX9~FhObM;Gg0kz6AD%m&ocVf%P;ih zq=yadBjyPv%MCMV*kLw@!>e1Hau+`@co|wv(4+r8Bs|Cdn!O)E=y~gJ2+capST3M-a{vx>Nh?FVRUqG{Jw#`>!mGZhZv+_!DFUy4UcgUmotTB&4-XH+X8 zZ_PepG3N`w%w3N3WuiP*0WZ znybAhJ>9FOD8%!5j*mc9fP}u>xjU&&^3?eLIR%hFnJmluE){ zK2Zr1+gF6CMrH3Hj&M+?4;Lp>zu$LjD`sjYJWs;AEh*Mq|E=cM6n4vOaCdcZ`zV}N zYP8XRg9r5I*o~@Xcy6Ww6v*bvMi^uhPZ>fG)kuh~r2eH8(^3+uk_j7OQ%;7zY?c*S_|uy?talXSXy z^uD*l6`#S&nx;fop~8k<633D3U*aECfybsa?pO1UkC+f*)l>HD$Fl<*`Fs28@y3;S zSt)M?x|T3D=TaY9u=$&h%VglY5QGB!2DK&ALaHJfTQ=;xVxgsPy5lBiUon=XyF%4J z4g}pSl!_)s-FV}57IoZ?9MrGim$y}l13@s}?-|0wd3J9BYaw`Ia) z8XrT>NKhe;M}3#b6L}^vs7ZVYzX|kt)uiL`^kD2xWbWr!!o!rt`o_5biIXvB&*y?y z^8iGLZfO-7|D5U<&&pc&f|xvM-*}MXM-4H#6o5k1##3Gpb3qz#P z1u{;Qjrf_dP}8%N;pA#jWS=wXG3sBuutj=i`P>PiAn(R9&=`UCb#KC`tlv$*3Uj%6 zMJxOI4)4}j`4I5rpwOA;?kYibMC5*xjiW4IyXZ0I@6F|_65+MNeFg|p_x&!VjF+Rj$joS;F*c6$J!#jr}68r z(9iHW>S*J*JN|9syF>CKOJlIXf83JCj79YBme9g3#qLUkB_^CAo~1MUezhk>*c61d zepVJv!9!v#@%k%v$nSS!QF0s5C~^G!HU`;DwH>rP8V(X6IYiCAu}|MPkUl>{zhY&r z_92N{;)PO*-rB7|=%DO=AF^%-u}W5wf2F?4zHX&V5Ssuz&)&$zY#qxpWC;oje{D5E zvq^?Zf@Yax)?UeI<(($bqt3N7)t9)VZv4uhB=+q%&9?AJAS8roy&$9Qs{Q=>&Fy^F z$ynY<>7F-c(tOh;|Gbvi>j4f6 zT;ys;<MYgK9k4t$y%G&7bz{8ujfBZC^vE>rBDw%<$7ewEpA^EfrnUSE!B!EWD3YlchJZ& zeXD?+XXa?M7l@qkFOg8hA>91c9%i3|?09}}^ezTsW$DxWPO|eZ61t2)p{#hb?5#~w zgGk&L-QZQS`@tVKJ9qaHDA=1s!m*>%99va z%7k=uGF}xRWK-(IaX`cBM$~J*!XV?}Q1#rB%1|IQfH3-l3K=L{=yK~|^SE8utEF|@ zONRoB<22!WPU|dlL|bb-Z5gzK!LJ#|K^5FgC{$S$ScERLJ@J+pgDJ;-UmyOoUfl3Ddo5#1>MK!qQC4Z?B&TZulCE1FX zZK{2)(wJYLNx87P@kYXDV+dU}1a+Jx*`4jupI{iY&O&+LMdq#K2P2MmqwE%h1UMmP zRx8v-?v+G3_lNF-qKuB}MSej7SN&xq&p}k=_b@oLz0A0?i*X_y}$P+h+5a5a!C^*;UY!9`xvRzZ)1|5AB}pYe;)$K za%U-C4GpP`07;NaM~kFslw|t1O%}_|;d!K_Xd{!)zhcXxYl8-50ew%I$6XUT0xBXy z-$2HiE8n&)Y3k|Bm5OCe#Y2bojk&J#H;|#>K^ZKSf8|-tAi=dGya%eGVHSEF#9x#W2k z8kIEI>a1s%;VIcGHv~^}HdH_T=9<&aToa$7=YhUF*mYP9k09uVL&4`+oMVo;(b}B3V+&zMq2;&axm^>rHB7Ln-QC-wFJaF zkUhN#c@|ZnC_*YYGB}~{1*Uo5CsRUoJ(M@bk*ONvuHIzIhr$vNzYtD(VQIo!f;(8MHH;+Vp>s};#p(daJkzETO zt34x{6!=j{(v1*C?LA64C5_ajj^)*KLw24Z=DmZJ9oMC5;SLCU8iD%}Sl{W-Tc=-moM~oX>9 z_v-OT7`w?p=`7z+`rJCSLG(5TU-@ta5Kpt|@>M@Et;;6IL3GM=WF6m`n5an=KZJ*c zq3$vtajxN$jWIM>rnXU?*evLBI4ilGHybG6&iD@YQJfiC|3 zm&RvClAk)_;HU+3a-MyV3o7OrS{I6^I~inbAM1hVB~6>-^vmGg6WmG2t2W{vJq?=t z@@13dTpE8rygirfy4mD4tv(1NOvYKflxySbi$`->&ak|w21S!B!x=IHyib>!R#S2S zgWGV_=%10}u-;Mxb^?Hr5Z)8=(9`HJKda3F%HQ5K1zJuLDwb` zm6b~Sc|D1@uz=%I+1amtajZTMJgdJSIQfd^yO74-da&!^y@n`HnZRQz>Z(=5-yq?o z%%P-&_?g?(SIXIA0SgM$aVmg+6l?0ci`US?ZA`f_54Pj(cY3Q^zK-|g_LPLD+lK&nUUO|e$gPi-lx)=pH5PqyoFyQomP-e3Hm|u13GURH z4Y*hr)?6`RHGAUBSCx)B(1Q$amdK!f8SdKx-s4W$sP{z)q#x}>KiTaDHX5P*Y5psOqaISQ60hCs^ zf6Sz@(ZT5y{13cL0v=)(Mx|~MBHq=@6`oL zGq|@w3kY{IuUN#-e$A!ZojLh^B>APpfc2hPnP~=XD7U4oF{&68nv(2FaS6fh+YMkR zk&bBy@OTK%rSr*;`@ZOl_lvwp?HA^?6lp|6QK0)M{Y#1lzsmqOzluw|GaIk_54_%S z@JJ_@QX#}?7kwUMMkD*F_l6Q7{jq@rzXOt9(c2k&pHxmrpz@f`Hq3(DT9ONGP8l^- zNovpZK8?H>qGc6FMU#lRi`Uvs0JDK*8ApI8KMPIUC9#HM;AwAKqJR^;I?IU#8;FOb z{O&h)C_r2A%bf2)uCzo1)F`Rr&$+S_BK&uvR1>8=*E$@y5ia+jJ0>hC_DVdj%D6e; zN9o?YT6M#{?q&CtT)(PQ#^aTWbhl3(d1LNvIZ z>V49lk%=qAJb#XM#fM+ABX5ZCyeBsMbJuUCB04{HY-w(>_VC8t5ANo?QOJdG7mPBk zoV?RG%F(Mo<*9EcSAqvu;mRq5k-n-4OrZV3i6xFiTp>A>G3R-5p?X9D8J-p{s^SGD z2Og_XEpib*jAGd&O5Qr^JkBhL4CCGBMn4F}jD#|JR$*~)M4U<2_TixQNh#xgum4Je zlQ&9&Ligc1P0J%elEB$?6qDdcL+TYJiFA^cviVvY)zRy3PQf$RF(ON+9li&LN12y6 zv{Z_m!yL3HZhj99_a%g?j~8Xn*Tr1AlOB@qni)XVQH1)k4Bwv@*i(?r`nv`vi3ML_ z)4o1L>_Z4)t>yzqydr#rfL&mB`_)~&Ff;RRw@wZ%pfL4_2Tm(-%V-|%{Yd)28n+$=H@VI?F$)HuhO8uf8i?}zO?%+S`q4x2RV(Z0QYO!1qWEj)35dW6D#$GDs^`!{raj3UYI+4Xcs zr%uPhM+e&4bfYGt_y*E)sUBhf-H*`fH+Ra*()+S1s*%k3+;V!t&+@4G2~CQ#Ha`ut zGxmK#_;MZvFrLDqUeyeK|D%u5c%|{gWU_?~Ys*R28#?Ivj`Dp;FoY?6rEb!Dadnqr~)E5`A=PCZ-bR|{6L z;Ho)b1)UqN2^Ar!Dsmd~7ySgVAzD+<6@sdhn_gCJ_D^xg0J5Q770YcCb*VPz&*!sy zx~k4NZQc-)o0RlzQ)F4@A-@weko+NVUCaFtA;BM6ktj!KM1^=e09-=&TwJ>= z%<+o<{o=03^^j;NM4o~aw(C!^9I54Z($t}I7A4EzSFN~FGtj5%8(K@zTH8WDky4bn`;>KozysGzi(2tafT>;&jd zt$M~qVG(Z=k;(D};r8*}#;?AiIo9+So_R>N(<*s>bE?oF$E2@IWV*Yrzth<0Prr1M zdL60uy!>2*<`u_$g|<$yX}@H=^5EgmyangcP3u)sm1Yx(QlgAkR-FG4@) z_+8KP*LAW|dx7UiRT>#I6qUTGJ3En%{5Wn)i?6Y?gQQi`Q&VrU^3IFpb0;~|I8I6F z+;LA7IAv09c>OPH{75>yHMEvYabGxHpH#Tr?e<=r*Iy1&#^bZ*E>PN67eTIi}Vi>lxF8i#M8<;yy^#6XB`6@=9n1HHecJ2jRaq? zioUV!dv!te=>LJjTl$>H-Zn#gao_)tZ5?%vGJSjPMcc&u_)kgUi;x4ot;(-%@Wn%K zIR`%P@gVOJNpN{vN`^_Gp%A!!Ee|(PCNj*M=dgz2*bpNe5`)YYG{PwRw(7wS*&VF? zlo;m55I6ZW*&iN~SP$ups}#a>8+B-?qQ|ZAJr1<`@Z3Y&+~0kU`~{s|Y{7T;JwY+@ z=`V&O)N!bOFw198WLV9JtC09QUc`fw5Ss!{3ES_LZn~0G$YYZOm5_TptS!XnOFXXM zPBIO>EPFip#%U)>$B;3dhMGL_BOfQjZOrA>l*Anvza2Z5o8)EK_2$-pmbdb zCPvW0ZyjF>dmLw^S=i6}-dx{BZ94fi*p1!VJvg58AIjYQ^Irgo${%!_!fAoLHoH+A z!ZX^HWu;MyZxkEFnXECmCVr)EOlznla`egN;GFgheB$99qnaS4s02r;RzA0PJAHqW zpo-aX4Z=uUe@tRCjtrzl7l{2-J2lW?G4ePV{`%I+y~_OebI}woc>gFy1QjIx1T;Eg z0@Yj-%vuauqUAm{Dc2e%0T0w`s2_LU>bq-2%2Re3CB^&O2cxbqqnWQGq-;~+ukH5< zr-Kw?SuxGVl=6itWgQF8zt#=%S;S)D37*ZQk$IkE2BH|xpq0r}?NC%zSlHcNMm+qm zrt$&U&2N%++^=LahywL0U9MdyBm-va=NUeiiJWjtAK3ddwJzl9i6#VOamxMBs!MF} zt|~KH858cDFSgHl7UsKG98rvYMDi@jRiU3M1LGFq#zPG0sCXZSralRnb9otoGnqV8xvmIejcUyEi>=h&6>7FqPoIi<3B3||!r6>>6kI8g?o8s|3i|w52_o=n&wO%Nwey4K8?mHHE0BZ?Z zl7f-x%f;7nN8KwIHfMb*ryE1x-nj6rdE>XZqA6daYY!~x5fAiYc=A=RoucfP&vZKP zM{Lduuy*LB;D4$}YaOh#x}Z4HyYM(*zRU|$O8klTc^UL7Zf-19c}7z^%ARdU2A?9T z*6gd}8sC=$SMic4dwmIPgwEfRZjS{RsrR6#-=~4`CU{~<(R>r2$<~qEij-Z$kHw^~ zc|LB+i*snsto7>QY1)>qY14L#ZH45j#H_-(2oa=y`R3kC`tTeY(^22X9&~;DuxDPu z3+D_v9F{LkN4J9NROI@o;_$3wc{-)lAIww41D)F$&^%4%+0lcf#3+jN2SYoq=W<$e zo2oY%wz6Kyu_)8AWH~3FBGpAxMXy`V__jiMTf9)OdkC{BgdUh1tUCg&2>Zu5Wd)Q) zKIpdyHNV$Onu8V&IUlg9T9Z+!Ejdq)R2j3PKH~M+XbFyMvq*)RM){fJ%4dv{Z%cTU zo&P*1E=Rcb>guP%o=01nX^dnQGJ72y-mM++*y_ELY=zbFE7T*td)`pI4e9cuRVPtC zW#`%#Bf5HY&UwZ|P`e12_3sdatrWcYMolYJ1^%%8WTr#T|mO`6XBVJRR5F=gn-%}6~h*df|`5Ryunk97<15c?V7?9J<)3kccT+!;tAaTHbi#|HyM8sHhzAODaVf}N3N)Okvz3g$?HFdvTJ71+1o2>ie`;Ed< zr*Lh%@2ov&%fT^3=rKk>M+TBleY8;Tpke-0nPPvA&+kyv!~8k^9{`s?Xuto7g7W}Q zXrE7=8}K&QW>=Fs^Mj8Vl_T;_!&huHzQh|OgouAVd*nBp|18YkJt@hbtlB2m0x+1{dQd$$!PZOkbD4+N)^MTtwNjflow=yK%GiQ65Pgpxh(v z#;6;%Y?<8Qv4!I>);UO29the|6O}GFWQ|Q?(7XM02F=dzQS8mN>F(XT&zq#&t2Qq9 z?2+CCw9&H73+wUG%#@~D)X29&>+y(q!gy*=2K_PyH*R!Rt8n8N!Q72sWW3qaLC1)G zq2PMw&6&06M8QAGAT{ns@~JJcLQ01(d7$xv9Od9yR8>fLY`gedzw8+L(KLav5SJ+p zyD$3P6sY)4w&+MxU#Z4i*hM8P4uy;tFz|sM zweD;3Loc$Q-H@el1N)lPw1z9{MTB-vpv*gJ_bA#9q<+BM=~4>;npg%st^r)~)Ilx;@q}&Qg)BCp@SS+Qe$+FbVe5e)UsN~> zb*5P$13#1wIWeOEC(!J5J_S@Xj^>Q@mgNW#;L|@@<(QE2^bdi6_4>NnvaE9O}>7k5xYVcLXz-XHx2jM>^&Pi1;n3w;|-8E5#4_| z#?bZ9?6l#-Hy*(&gbmX5$X>r(hML!=5J(RA6i0~o#P85oKFR<9ZrU=0gQ(IkP~YNr1*6oWvbcy?8yYC(WX*@`Xp$4 z%=4l$v~Juz?t%~tREwG=NRA0>0jlsqw77w%jCR;}hDUi?EAy?i*b5!)@IoN+p!qb> zd`Kg1;q^K~zeFVclc#_${nO%cEzo`rPu=C06g2q+xcIOC>bwEbit3PSgNtu9ZIyvn zBiagynuoQ-9=HrO<&cJ+m>d$yaWa~eb%bJA=zpjRt-6LULY2duWlZjB*RyuUl(F)D znTcDPSep_ip8`hE4ol|~z{BESTGx$A{w$L0e)Z`eHbQL1*i4;_DDw616W^vgT5w|}_jzg(t2_^j<{%wd}{vl66^qf*ObM+-#NrzXP) zxP@pN(b{Nr>MD9ZF5|U`YZCuZ%i%Y4{{9<3^C!uiz0J#;JQF)KrTydeE|2@}%T4;h z1AV(4U*fpI-$;~MlP5%cNCb02CMThRA=pd}*W&$W-!MKo@O0Oo{9Vf{UGyG2Y;gV1R;~g1;)IJ}&Em-)+!2bU5H7G< zADK16O<0bA?P4^XZ(NfcvBWJvshzh3jA$=dz=T>eaZA6BA`fG?M=THTwk5t1u6KF2 z9<=MNsbz^PV$4||zM&6CCL*+*p9Sn9eh^_k0Xzuo!$$S#pU7l^_|rdZh`Q-oEo$=a zYO5GkJj&{KyS>#|@Jq@vlV$q3@XH*Q=`EmP2@WKlR$($;eeF%*5#zz^#iza?_3A~F z28qV5Sbv&sasK3ldaI>rheMfbpf#E^puqcvYp@jbnTK4Xc#;(hd^qsL@1cj} zu==*pw}tjv9TapkWq|sE0h3!n=bg1F1}OSV9x6f@d{$Y;{fp_PjE&59xuqAT4sWjkW1uBiDob zz>a$wWVlwG7lrP1t*x}*zhG;JM=1Wq55BO_CxAW)nv4z43{e0c30TDNom#|JjD4QG zhPTtD7NT5QK@UT>p&uA2Rs|pH7ZvlgiroussSPBN_JEX2E9%E4c~W`hm7-PcldDy1 zAtjda=snuZr+^OBVe3o)4~sur*F`9=4`ox-r+=~;ie_*K10Ka|+GSy!4^|uYddI9dSVkr+B#+;S5f-VElD3<{C?^By2O{B`qu0%`J2D|CMkmm zP{3*gFWfe?(Em8CjL`bPygh2=(fmELx)817C$ej-ww8?LXs7f<0y`%{?krAeCXBuUBHUSVW><14}*JcHLMDKlA+A zqryU~%TtU8a&wL{p~H5l22oFm3219ELKV#;9ZX`BRxJE*w8WufH58$x_83|$=dNdb z`X@j1i!MCoPygsBv1OkAzyJ3ymxnP6eEEZDMi3&cT3R<^jesVVlBS(1D%V)uRKYiD z5T!<{RJEzHrpYob<+2n)Z{U8Tms%1wehb*10^YPQecZZ*Bu^8*QX?t!#E?m#J~c!F zs9y%LBoOW3pIr!OKVBl_8<~#hdiLnUw`R|N^=W0-IDh?_?+f;Lf*VR{FvzBo<_m0j zh#wI5qlAIJFiK!uwz9^gIL+D33FbU0DA)>7@Fs0@&6yBW$h4w_w9F5uNEy90U}JLV zq8)h?Le7|G0~Uq$Qm^vYLH_!`evwa?^sjy(>CmH~Y``ldawai?Eo|qC`IxF_5nbXu zLk@fO(KgA;iFT@>yUSrrno_j`>89SrVncfy4~@du%!#(*$QrRc2m_mu$;nj_EK1vC zIuZ}(b+rS9b|oO%@4Y}C8Yj171h()RdB;6G^YFU1^sH}<=*EX(`^H!yfE}f}3WZcp zwQwzKQ@D&4=33*GFi|u|mw50Oh9CT$vr?URucxML(4kQ)4ih*2VS)1wi-mtxp8R2h zdhzEkOJgeJw$%pdhR}_L##${U>LBFB0wNlL@0Ds(C=fhc;=P8yT%{088>&?v z*Y33_=q5BMono^S1$p{1io`&?X~n;-?bd8tPQ2ztsEMrt)9zy}JRW0Nz0417!^RK9 zbDA~!M;TVQt!{C;79b!eW;hFjFG?wD;v)i#$87+39Fxk?8f@hht}6<|3F@{GyCBpU z4C5zjTluHc$wuL$H5z`epfZ0rks`xgsZ3pPGZNW1ceZgU*` zYm_;)#rIaKo(sCL^Lkyb#h~=FN-Cw^AqGj~OBM9hwNgHA0pbRJEDW_L4FHF`iyKw9 zm8sabCC)$i`JEXK9sH91$5;e6*bm6cR7#rwUt~f=x-!~O(ZL4{1ySn$Lz#N3uB+(^ z-K*9*C3XYnTw%U+&iE7$PxIt0btZpE0QolWL-JNH4kU$@MzV;c@#+c(5=!w!Oh2UA zX)|w~MW<(d^TM?e z$LIka6t6`dNVQUD_vqH>@0?$-gf7mJ^6Jz2Q70#tI}ZVAi|gGgcBY4#cBY7Qt79m$ z8>K^>+ACTcu0qpB4Lw|`o$v;XM}@ih(5pY!V0k_W0+|3F1a@syx9!@2`N{CDu-XjP zQx!LBbKa_zRNg45iY(HljiMDvLq~RATOzas-Fzpb5;p<6>Er267G8pBBPSed;$zYkaiIwrcaE6rTRwdHt=~zk28TPXO=V*T4H4F^M!F ziARHU=^K-s!c_vOyT3{my`IF1yqw$!btq&_rnJp%kd^Cl;MZOQ=1^=JKCokR zqVk{>GH|?J5WXaXyneJnw}Vcfag~mY6946|K4-uEXTSd^pm9_LPu=R}uLG!~4g(TX za79v`M%#v>*cU4ug*WdpYpovarI2h!Mb_U67do;Jx9F0(B3>}kWi{ms* z1~$@x8b^)ktA^n$Zs6pkHP6(W#KVdnMtbB^K!fN&)qKuzppIN*v%W^G=3Br9gl~Xk zjdAk}~IO7n8wZ5?Fm!qO9*e1ohQT?vc0aw|+nLzIQMQ{JuQ-!=YH|+mpSUXWPDh z`**$}+e%w=^27cF+=Bw7AM-(iq1nYbrr*#cG@{YQW6!rXkod(abs>4@##3DRVdwJW z&PIcmLqF?>q!7H;gQtFo=SPWLTds*gJ23enZ%HIkesf3O&R?^<@%rofc{0CdLo!02@Vm* z9*r1!gk3@eE=MTRI0?lH6eq{+M5_N)Z!|nm*e+@^I9Z`U=}{X|!>qJyKGu9EsEAq{ zHX%ZnBVK+Ekf+-pXb1RF6=D>x^%U!Arsk(V$5Py7i$&usTeQP zZtXI!MK0uAnUhgq(qUD6=X_-Fc+2qN$KE-6@9+QN?A;&y{n>k@zWw&c^eGZ=AjN$( z!um7UW+zJBysl?I`JMa>hzPLR;;GQmV(qvc=2_?3C7~8uH%IMTo~$BU+jb^ z^uRvK8w+E#if0V~iJ6p$7L(v_an2v`RgwP!)fGa|L&>TefdHVZcu}#BjO&KSQ@N*x$;_)I1&i4 z{9rI+1~O{}bKRF)3@D4O5NH5JDpJOSR^A8`Xn*{QG8?tzS2K2wh*J(w&Sa9*_ax;%2ukZ+)7B)pl|7@T41SN6XR&_yF%zUJ`3gv z1HL(W^R_&lL&C6bvG!uK3}piR_TN2u`}gHn-k7~|=k?i7e|=~6-#z{wHIe>t6U3Jk zCcI*$E#k^ix4;qGsGkyCP*B*|=UW>Kfe7y)End@CK8zs;cQPxA-tvr;% zxggq_Jg9tV#hjxwcLecJq%Cxz$s<}*%%ANvbhM;_C!AJ*R!7SW1++0<|24B~b;+>IUFOYHK!NRNzgN zMN5I1xss-hz3WOp8Xt`loMI3Y2dy_-YHSD(^;`0O7r)dP+F$N%tu2mc<42p+GeuQG27GaWq` z!30X9eMIfpSvcrDh`FkUezi@T^djiRMI4XEn%3F?u+Vn2FOChxa?&R;_Le!`j`Kra z?Sn?(>I1&YR9CuI32nOaPC;JEfDx(kzkTCwv6xCQnJ^GIN``9V*Jw-&Jaet$E`=y# zhxZwdZWDtj#-bCY9ySMJ4SRaI4jgFwQ>F1L<5?7SE+ElDAB{$V#$YfS(GIK~AY4Wv zxFQ3s!Ds{0cvY~8!3lT{4tE?_Rpu}L;;-yF!+10aWRK`KbY|PtbtB6AI3)<$oj)K{ z6wpgOa5h_$kr%D^-Ow&Ls)zc(7KJiXxL$?UjtC(~tfCK;H%1$2m^N-Gb>&4DxtLlp&?N3qQEB^e60k6%cR;n2N83Hk6Z^VpEqX(@qdoS%s z0(e(`&Hv?}|I_S_d|MO=;r%xy5tCX69yT27fR42GMkzEVF3QZGO);mZK&AEOC_Mkm zX~~UVJ*7Exsxb4-LjtuQjL&HiiL^mEPH}MHtwa@RyB^$}GF&LuU+@en25#egi3xKs zX22M5lQ{fY3KL)}$Ip2nJPy1?u801N3lcrd+k>0;)mTS0W68C!~zs`Jvbr^&y(V1fK@RmY2M*) zG%c%farOg$(-;%sJh{@0>KFy26)Y)b<&61(y^?^O^9p=??l3rr^?+RHGOu6)4_h%{ z993znYyZ!_OQ>*pBwd}72->o^g)975Q#Ei&JOy$6^FhZ^li9<94QY12CtytZNk5&&^| zY6gQi?&l^jbeyBwY1VSW^~b(>2s5bqcx51ZmVF|SPXPCc_JX#!4B}>q#e+karJcb; z&a=uuqQI7NwK0(@z*tdFVcgH9y$)FPC!fF%K-bdjrB&gS#9~~=mo`G}EuyQvqlBPVZ6JfKJGdW2 z5je`_RK`R-ttev*jbvx|n;w2V#2fv$14-P2`*@rDfqYg{<`9XyBw`PE+!Njo`E1>g zR$KZb3G{<;tu1X@hk$;d11@W7m%sel-Ptc>9@i;4CiN^W6{1Gu{Q8ZwiVHQS^7z1? z!x0Y-$r#WsFTr3F?a5a=+T#@D2uhKhRIdbS*crk0pyHuKl}QX?c%c1ahnPVJ8^(+> z#02j(EldWX%C$z%DN>@G)#ssKzEC7Bf!~iTPyX1R`p^y}3Od0ewf=|DWIL>E6F^Jt zfmVyG-~^drNdvLI|eQF z8OE4bS@VpI?d#{Sycy<0obZ3d*P~HAuo+1KVIv2tL9H97%Mo`tZc1pFR1r&qsRlhXfGGAHGO|45BUdoY#|$tn7us z<}h$9PzO)YWk@Ec3|9JIDoE6hH8}|QpByZRgNijKH9Py0^SG8i`obagbQAl2;UkI25-gaitziQ4@KjSLzTCp_jTzKmoRXqD0Ub_7o4jRRGw zozd{b2FWi2VEoXUuJi*uN)aeK`Rx80UELh47zB33*33+RF^reSY21(+Zw6h!+l(9- zE2VKpK}{Q{rS_*wkk=jwYNBwSQD~qN#)G{ZNE`+?zUU#B;@248P7*`#`WB(kVoVyz zpBQNJCvEeGoF7OV`e*gfz?InCl(&N)$WOj#Z{aUKp7=3QtWN^Vw{OJ;eX3`t&^CIg z#Yxq=)OG0SP_~dmn_@q}Xw)GB8I8u|)dpRPNA#$2TQLwc_82R)#TIj8Hnhb!Y!FO5 zgvPjdU@ek18GRA2#_+d-kqqK3L)uHiC-3IVUusc1v{U926aq8@VNCaDjdl>vVyXdo zL>>G?qbOD>Gk?}tVk8~F)em!=@efq#65^$-|=h5=zk5EP3+*61jwF|HjG5>2x!&#_Cv$9lFeb(e zelU%N``ADbnLxLWRG~#J_~tJPzWs}l{Pb6^N|#+rm7by~(!r#XLj#OuD5;jDLzYqW`ND#}Ph}_82{x>!#M|k@rez^wJPFh1>WwpzT(> zqDL%Y%&lBAl*t4!F7y(Bdxj87>(f5Wp<-Qmc~g~h3ptWN56%wXrW9KV6iWB^}IfMWZi;QEGYY$yyAt9$CaK*4@$f1bbn7m_J zMDXFbQR9%dQR75j&YUZgvuo6e-1-g+?Dx=dTtXoxmzBsUM=&8s3yI*a2kpAz2on!N zdvgG7g|JG%Vn-7>*|ntj0%vX5!&!6MEX^^(VV}K?toMsRCV>0Jo2`A}zkW_?EDId9 zl#M|Mef90N*l^(tJ>o*-N{5-)anWPLGa)t%+b+ZcE7;n!6^%Ih6C5gs8Zop@_cejV z)F8BCquiW|qx)JMWlK!##3icvP(oT`g2Z5~g&N2t)Wb%K_f@$?XS6b3T!z|d|Lwol z8hX7&w;JO;z=)dSXd|@V1f*}akzzi8C*~3xJPo90l%Zx196{wBCl9S|;QT6(Fv==k z=m-S3D_Bu|@DUOm3anHkHRewkBn4=PwVdSno!N@Dg~eEq$l>YW)s=i91PP!iRfj~+ zl0Lj;j33fxd_-46Y-eJD*G1 zh>^=>3izu_&QmY4YkB$y9lp_Xg&Ixbp{{ib)v$;}|7+Wz(T8%VE8>e#`&sNY5^Pzl zQf7Fj3MXGLLemevE<;}z75XnS80waV8(LQmqtrFbA?uhiH)1k4gds0FwBAf8v>aC?hWeDkgggom>}hb#Uwz4FwrAy7LdtG!eZiK3yTQ^WU@1qg$Q2k zeOZDo1v`jnScDub=)%{iWii6pF?YRD$xH!07~9xk z51w+TQTqXVpDc9>Xh4C&_BB3wf_8L1LAOWH zaRln8ad;j*VVb<(qehby7_4_*9fsVvY*KhJLJRc(DwtER4Fkxe;V9?Z@FrIeIkUU! zE5`WG?xWADGa2L}U@}HpPGZEd!O*UA%!$(s0C^C(tLrf5=%?Fgn-~##sPR3d*3ws8 zbK0=>7q&3YvUdcqq_Z3Xfx!ijMWK5v7rfBAIZDS04I5}F(uD(E>AS|zg`{>+xqY+@ zE`d~kw<`-Ww4rU)7HR8xuPq{bFfdi7h)BT7B!fyA;}H&IVV$8MGGnxLC{}JNvbW?d zVEy*5d;|E+yR(nH@6lq(J*sd+;y?a}|7R$0&if^GXvVc+AVU#t&DUwirp!fkQA!M< zcS9^_DyoAs;;ODgXz_0K~2qf|2@nCjO>U)yN-IJ$z_vERb z|BYqVZvpSScHHBSZ}UgE`GpaC63A~7TXM*D&`1137vvw>B^PT947{Iavp3~!;Me4B zV2a?hLctAc{t4hPMr^AeTKn4S;HP8I{*$*6aeS^<1qW5_J(?oW_qBFKSwctv(vX2> z4H`CzHROEaD)XmLUTqGHXHqWqNd9OGc{~ue*y>yOn&6{7)$y~P^s%-F{qX((0Bu7m z?KgV~Zod4Nzxu2i&8rql%KDCBNk>5&0vARr8{;ibC*0f|h)t16IJlI9x;jM4O^FHx zzO3yG)+MCP5S5ymRw~Nm>^^RzwaDQ^Kc&f79LSYdf3hN^C2KXH9ZZdYo^-2#d6vB) zkWT>j2KDlWWMAZ~Sk_{WMIxW5(uFCyvS5tXS6;f1_?k;LCS6$es$Mv})WUux6bqlm znFLP*sKF~TXn8_A`brTE^2r}`XdL!vTVD3za~^2FzRpt{e^RKiXl!;h2KvILj6k2+ zgTjjeWZ+>*V>S%|OYsM1dew}a8EdFRyZB#ha9K0Sxq%#m?Xfo&#svPt+ zTg(IgN&kqW&<}*hwBn09ti=ZLvG4G+oM;bv1jGdM$=`g9uPVDH#lhWuU*ev@;AfEv4A9Gz)SRAo!VXzU(!w=gKQOg3c#eoHa z10T%Bp&O+x9IQ#|jZj-#RM1rewX;}k9>$ca9ZXr^7YH}&*Kq->7o3$+!lY=#qn36+ zlnBKz`XfeA(BAcdfyvk*Dlcj>9}oJcfAW%IBN5C(E!QW>=%gdJmu(95=lKV^nF#&1SG9b~qN&Ij%WP>MY3qx{_4wW=_CrP|Y9Df;eY~;$KmPZBr+X=u zUOZsLH7vr;vIG_@mrk6OV~7LVuHyyWIYQu~cVGG|OlD8zGzw`QAGkkHt%Zo3+X(Zax2>xMax9QfUYt^r7M>10VrDy!pxaIM;aP zFG80C9blyMtZ0!pIbvx~5eE(E9Ks(o^q5Df%uX06sh7%p3ch+F&;$`YIL%Ce7LdH+ z3jz3f5I!-ocpQ@q1lMqU`e$m5U~SS?PPo>cW-YOmr@7D=u@pI#emFsyGeIxr_FO`P zOgYC+(jIZ6u=1qP$#7i`o2a%`t}-2?_7#$P=-i>nD)TV`1D$KdqcghbCjsEmdkP4- ze-04)k1+i5hlr)<^`f8m6FHs+0s@<740rB;A8hC^wG|zw2=QMIooAhI#OK~eo%G0T zNL!UrRSsE!x=CU|xg0W~!Si307bA`t z7E;`}*qWRwjSHI~r5glM3eNspZ5}EPK1#CP7y;&HTjE*>c$iw9D*(!ev)jeGs!i%j zQGi52edqPNvlk@sd+}xc>k&Rfm@A(zz2UiruAP(U^?yg_qG1dSiE}M#of1JoWayVC zJ;23z5CsMyK;u+uYsP)l(c;k=_GW|%z9uR+-oitzdN^rQQVZd>?7LjFsB!2wdF_dw zT-5rRK#|)M!T578{7R%2i&}dUTDr!ppnzLoI2Wwoy;Zt z`9hSn`f_fO7-;$Ys_5a z=OZ9^D2t}LF%4de$<|MJ8K%sK)}XtGL=_beHsE&F8g-BbJH|0%&?thg#-OdzYF7-* zPHbs2AF~)(EO^p8*lSA~1NPLTHo{{=hvM>Izazf^tcdLQoKFDvi}!qM*lc8RSY^qd zKlMZH8?QbA1g}p5v3TT_pEz<6iJXgEG87@@RUSCq_DGqW9e{xs-|R$267dj00{e?H z#xt3|!V?Qo;+16Y^MJJ15E|3%z%_EVW(~8VGo{*l@F-N$J+KHyRt?Oi2#d~bYCTYK zSAw%32AsfS@KRgF_WdSOo-EXGg@R4-;RuLk(GQ8zVT#!eX&M-Fp0x{Xn>w*#zabae zW|}m3m3b}gA`tCFM$)bbF*KEWfiXQ5>ehWChzM)}gOnEW8PzLvPHH|NLdQi#45Y@F ze)05zn4uO+7|>lPz1Aos?udkeK+dpWO&E1*W5(*jbMb*{-Qq#aU;7LOgYlq+aKYL& zn&a3)NboKP4HadAAyA~f%Y<_oZHnA{z>9*IiZ7Bjji=W-=M6E%22XSGpL4{TLO%72 zPXO_UQgm*`QiAb-J!twNh8AZLpS_UU6F{x?MFTFKnhb)j2R!kGo<~4*3fC>3I#eMSm1AvsDQUohAZowU=}bHiQIH>UwM`&p+z47u znGM{l58b!2i#Rliyqr*u6pOGHtzoL%@N!45ePIH73!pmZDqgY>toT}uG2+QbE5oO? zmr_1t;d7$OMB`wXc9h(0jc(^doEIEV>2Tntk0*p)N(9Ldgjh;EPpr@FrC-k#2KE&` zX3VB?J*b;Nxm4njKPA91C~H2PVa4?=Hh|(Y)<$QOr?sRv%X1pF~| z^dd~os)a@`i^KxR4W7|9{o}}sEFYX~T14PRHF?r>>pC*TMKPS?3G;^=8Sj2*qZmd6 z1T~$St>Q>O@YN>u9*q@>S7F%miD9&2=cIWs(-3Xdfj3tbS@ICb28TycIBq<0 z@l&naWvO3EmL51R8dIo(=J+d9jt6Ph)a%c6?#kq345L~$5~ZEs1f$5tF`{WpUb;#{ zCY<@^(Rj*vE?TYpz-NsH#QB>q1rkqrJREQD1v6XPm=BN3j3h(F#8IR-k}4He{IJPA zd^T1VuYbcfJ239XO^iu8`B9&daRt&t^FqYz5f;%G>V_#6 z{Uv$S6Mq1NAEMZ2JiqE%L%qpd&M^W;Z%ZMKfws4(G&%NV_Q4(63d1L`d>ep0Ig-vPKn-xfxsB6r}4VAu#Z6!zAsG0u;wIvS>pWwG4(s=xVJ9o+m?4 zS885ARN|t-Ra*acfzd_AA!GGRzw`^`>d~|wq6t;*5<;cKk-xShH2q=>CT^T0!7b8stjJvbK#({fAddV!A^tj60#+w3@kNj>sHP7#{=lV zQy6_kFQlnt#S*%PH=XzZCC>dqZWI;rqFOdC5f`gfn9|ZXjFQ@5Kln~>7ye1d)STp^7fHvuQtDZ70dRhcH%}B%D_+CJ+ zYOW!QM#-Bow0b$nRzH3Ei~%4MQYMTGPWf7_c=M>`>|18Wqt!|kG;%|5)vgnY<3}AHMgqa{lhDvKa#%Q^7Bm!P4CO_RT+M-( zA^os~(rQZCcXl3`EA=Yf3Lgiv4=*8&x{Xi4iFDp+Rwi-FK1jGbTGK2|lQ?F^$ZO!p zYN=0&8fhH6G};n1C0YkOO2yEsoezFqcu3h-e-Kc{yUms_1s2yd4t71g#m~MG#=GE} z-=g7xrg-pF5X~B*D2x(?v^K4(d9Jffd^im`&;MS=v$*#Owkvvh8XrT~lQSlv2P@Km zW(M&;&^%)81o9SSKnDL2ZCQWZ@L~h_v^{uA`@jE>er0pvd5Be+KdGg=fvRmY$c51!oS?RV z+CmvTC$Z5s2|>qXu5!_wg!~H|)|6Q-CUoA;eu5jAcw(&Tf(43;R6HtTMr%F8A-yFrLoZsC7r#2zq}c_$ zV6v`ObT64>#8bwag6|u-x8oXd8>#&L!7jWX@9F5$Ar|sT{#bX?c z#cCN_I!jFX-Mo+Y0OE&h?d4q9o}gU5eC2TQ;w8H{(Wgq(;m8vLE6qX;f#(wzYqem4 zTf5ARtu)_iuZ)|XzDIrFnm?uxHa0G~>4LR3iK#bitS|LRCuQARQ(zdIKKTPc`ckJp zWQY^8Sl8lh0`wo}HfcL=YQ5HTO@5YRt?}ku<;KEvR#8xwTr#7q`6?X?A$xgV)X#I( zekt1vwAznaW2io0`~4Ae_ey-kjxnc`ps6Q7wa7vfB`g&J^58PVnSP+%rnjY?`}Sbu z%^}_6FW4YVt0Py~4_^D>B6&f6oIUVj1Nd?F_97M*pe&WPiKE|C<>ILYlnaEt**X?t z^7Ua8(Iy<{T3bYYl1f%-3XnidOw?f);+V|xO`+gM2ula{FF(@oKwkn&4cb7GnG?wz zWj+i-W7)e2D=Q@QFoL!z3RPDdq8_b*2KwOoZ4efUj_9jiB6Mc}7YU9GkJ*4;0vcQg45=qm?_x($}Z{R~9+!XwyfBegko~-=s zzxCzAx$}63Oza{y%^7tl%n_v?4G~z@@h}aKCShNBGeI=kWk7{x?bC(}z1Q;Po4wAK z{$f+eF)_NyxM71Fi_G|FclOGfL@a#(ulE9a9khHXhWlI4ab7^roTER0yMa5qM+@Hv z=o>&AH=DCIduh!LpRKd3Y+fJr+5?bF7LlH43QtM68@kt{j#H&wPY_5wcrY7_Oic5I%Gs6@TU_1W_XUmtjNh>af%NaK^gd2z7}R*(81$f~wi; zKDq{(t6kn6=&g+sdY6nC;{{a9-B+dIRDOzY!&{=c;JcnW6_23zXo-|gX>v%aWL|{Wk z-f}>r3+OMuU}fM-C)yNn@r(y2mmZjmAdIH0 z(e$N>mpIoWT*EmYNc!M`wUInFY|vKgXf}?akt|e)KguXDCG~vhAQ82uE0xp&4R&E4 zHC?GtfD-angOU%{2Jna;LuT3pYJKL$&mU)TcY$wy{}2Ade`26xN7@Aj^hpcTMPT&A<7io$*%l=&@2Bu^p!zAPZdZr8Xra ztQ2ACk6ye@4!w`}|J}j+C$8Ve9V1PzllQ&5vdsh;j-UP~zDALy6fRck8K}uf>4Emy zm_#48wz8)UQ*^R3dG~cf>BszO^9AEL7b>r{1^D$EIwmyiww?Rr+e}*NPZR%p-}r|ZGrn)rUiOc<%bBRzIF40%Gew!Pso6`c zy54dyF=E@>!^aptNI}&eVw#K!*#(At(!eEOY1)_%+2bG^VqI=_qmB6%m@x{L8nclC z2kIR!t{pT!N54|5;a^bCz6V}x0H1xI?)J#{0cvlM3qca;BkuxY@r;F)bv$el^#v3Y z%@$BjLZ67Jg@H#+#3E0gx|q13mn4jA0@Ei;_zv1?9TmeV8Og#AL9=E&Pl%KrK`0mL zT|sD)%`WUi*)ViH$0depj}i6wv|OXXja+#kja`K{Hn;{~jx6OSt;gn^<7Vwp4Mkos znR(U4n6U;f#wReUrD2=xK|(m;3mx{3n_@B^?dZ?)fl2yI|A7r1dEsd@JZNNE$GCsx zT4>>jq>M+?y@#KFBO5ZxH4Y?BQMj4l7;=1;-lE9tsO}N!0-GEY<)>LHh-}(v?r>)z1FXY3t#D1d9<9h zd5yNHZTmYf?*jUq)%a;H(g)}A=xZSu0h@fR4Vxe-OB}Q<3&ll+?zOR0y<=R_pZMn~ zk>>yzJ6lkDEYq9QdaghslsDsQlXQguDj#7o@{SGvVA1X~hwCri|EI2aUWXT7 z1M|Fk-sAG}qB+CwP{2Re0)1THgjyuI3DnCi*sk-JK+f{(VE~K;oHfnk!EcpU(FDd9 z7t<$bK=90(JSoPt+NLgS+)QF~02zd^Ri4z7hRiOqc5$R1lMxP%Uo$``Xy%k(1;B`= zaJ_;6!#o^^nl>!7XOTCTk(Un=S(qP6!A#hqx0ZWY$!Tg$Rf5J)Mn|V8%sDq73fpXirC;DlCe2{ELk_gIvBujE)+s|_1tA}_K>Ch8FA z1yPTP)>u1+L;%5Z3|X1H+ny>jgI=#8Squ`M?4FpfgM;u0!k+wH|uTd;Cin^BJ4~!hkgI zMg+|ablSO|G;>FZ!a=22M5{M+nSbSo2}x zssk<7u(IPh=Qc6#lmjC%kj8?!;2*dbFaM%1o^yF|2k5z9a$4zxMg|!KwKjb6YYvWz!@fb{2 z74Nh=NIiM{>k&yd-V}B*FrU<=K>$$~GTam)0b!52fJu{$!Zpe+bXo?vbQurDH-7Ek z57^QAmwuUF2<>AT^)S-SNgZ>`CQ^(|e&k7>G{-( zt2`CNV%vBaO)FO!evOxZV(mdA{fW0k;N{|s1RdLCfAJlO0-4E^`{^;Gi zGUEm2AN+&ABlIfD-d}{5C*bQU9nuZ?9pB>E_%<4 zwl&A3(X6+A>iGNrqBH*?#+!F;`IZfPDY(twoR0x!or9M|)w+JBL&Ceu%)S{@TM_hJ z$Df<3pK8-QI%Q%fP&lUf=vvn%E=dz25AB!QcDW@2LLsxA_4Ov#SH3O&~7bynd7BCg7wce5BpV_@>?owKr%q zigyFrc>1_f#uli}V_o}W(7}%F@9ZdpK(b@B^o|c&{^*s0@E=ov?E(Hhjc4S<&Id3{2Al3#Q4#me_bON5h0~<}5uj zW{rP5*r_LadkPb;X8s;Of{e>F7$cr!P9vAAOKEXNnWNL=i(q zGAp)bC%osFHu7sintC*gC5|#G+=uAH;r(0w{y*?GW@it-@+*Ir+NUai=NEns-v@Y6 zK69tQDH-##($h%Z0aM+!u(coK&9*~e52s;n{o{4$T{^`&rk+2<8DGya--tmO=bdUC z;+(S@d~5_$=Q7F7cVg2Y<@Vrt5PIfjWb=P&BElQZ4MLR zbogVAhh-cOVFE)VBtSe9NXn7aVmxWv%_&$y&hX+_O z<5R)*lCWeFZ;ke)(po(hXv!Ks)abQiMZvcPw8~->VCn4%Z zNA9X|jxU8>78T_eM_v?tJb|@~r`2d##}kMD)<^PH662anweF9vZ*+a z+u@om{mEWF9Lqe)g8;#ZnLY+R@xXl--~ac`_wQ-^^c{Z8;TaTeC}JG#34%NA#_t&f znz*oW_I`+d0WQz~+CSb8v|)eblK}N?^P!Ce^)6QppJr!$8MMW3=U0nMS(7)~o|qaB zJsc!0eBp~O#-CXH+xTy8z9yaJqF?&EU-P?v7%klev?p>@+mnMba3l-sE*8}e$iikW zD4q&AV$}yej!6RqQF};V`-@`?J}_Zlc#x)docORIo&p|?KpljMtFDjbI8{SVVM^C1 z{5q}MZ~mw;FOJW(2VQ&#rv(-5iqPRdzZO0 zt4wp}gYQ>w6WYCnQ%A%Fhl}NUX}V}flb97Z`0RJ~q1C*=Te~^TEVN|{fykPh0FJF7 z7(42OvJMx`akTV+(s@WREr8{_zbjTUGfSOd@J(BHq4qitwEGbyx1!V@nWRkFF|=h2 zIVH6)pT-9?MAqMD55Mr0&mC^R_uU433hHNn;*G;^{L9;DIr>TcF>w|NRFzU6^RIw? zv|VUz3^{`OAQ#*+q=khPeW`iO{FtZb!%9=1cEH$j;A2I5A7`B>=xd%!jKp(B=NID8 zWqfeV&N-fg92&Nb8QC0BhLT5KP*VpPDixxgKxINnaBz*p^m*$(qP_n;!@IpPJT3%H z1&Z$hWMj=|dNw{f2R@pYM$u8TFt_A;1m#syEvnq?yW`@V?zG9!V?3hY*)L3={b(Nu zGFs;*Cg3l4l3A^QOkc%-l_Q>k zYvI6wi#`|ne70R_E-Xkw7ab5;R~a#>Bf)?33(|Cn2_NB?fQC2PZOkbxDV87UnREP+ z7S8+_FzlO6FL!G>#WjWR|AUV|f9IB9|IfE?+MlL^~QcA5KG%e^PoJGgCY>3S(2?SaQMpId4Bm-|T07 z5|qB5;X_{v?So=7k&W6Y;{a|zkEyJ^>?vzE8*AMaHRY}s$Z6r{80#CH5AKuxv>uRm z+RhcTxATeTk)LP41#Rb7(aAo}HOP^hVOuVY8*;^qJUl*U;I%hjZDWW8G{3U;c{iJr zko~Qn{gF2BG^sIas5vaEN#__P0j(9`Kt>&M)q*c=I!tB432LPRU5F|yX;*S6>lm<~ z^njeYd<=4eQU@w*=)b28M>&gZ=xe0IU+S9A>Q7F>QTRxq5nNC2zV+hn-_R}3jeT(k z@VRyU;c$1ntE3eP&y(yg$pNN3eJUuiwb1oq4c#w&wgo2-kui1hQ^qH zEN8ipg61M@&jO6*5~*kVbjaB)4^h)HXs_umaZN8Vuv~1ZHg~i^5#yeDdq84aob#C^ zZ2{F?CEA23X%sD81Z)+z@d`*#{116kxEFKC^i)7t{smY_M;)!~>kioYDAo~1i^rig zngG{%`<-LO4L!B|N(J_o@|288QG#B9(8g38Bz7k!zVd^OH_z${4+oiYkaN$+Y1p&R zbQ^~lr3V3MYYG!EOyxDK1#5Qh2JXmH9b-XJ`X){PR*`Fkxu9tLRg*p(zIXG%;ZHtz zfV&H4^vPXrN}grmrt8y67)rR2)x*WQEsK z;6U}IkD<;T(k##u7hmzh5ut`HA8Eov=Wc}wntCggxc?BrpW$3CM>y;m!!bZ{ttSF_ za^^EnF{qYF`gVh2SJFx{)k&nvi4_I>*@mv>0qRD5|MO{fTy2&P6SWI z8ZBq84|X=(Zw|{^lb0n{>7g|Hvt)7GdwJ3@$KV{nBT$uRF7+V7~H&Pab~r_x=KY zy|*3xisxwTih5HVw$@0Uf)J78gIdw1R(D#jARozao}fRRv|9F%)G>!A-euC0 zKo8mYhP70|cLQVod5nBwE*Vce{!#VC1ZGt<9$!W;T}7S?!vbC*!vwy*{>4upe(Mju zi**oVMql0^oHyM%vf@gOqAq+UE)z}38nGX_=E1@<1$!WOjGt&YPdNzE%vmxXoTgY^ z37(dEvM0L9iGfH(e&od+P~rXOkOp)=DOcR^6t!vj-oN)QT=Jl4ak#U1nZLISR=?N)o}epl$BB9Z8-v=EO0$7YIS9UYm6yW>ev;W;d}omBZ$|pcrT#8FOZ8iF931ph|%nQMzGVxqGln_Ko=QBc|i|3 zWPy>Mau&1KbwsNYbG8Lda6ugFK704!N6zHWJO*!3<0vUi!s`7S_8oEGe)qQ4wBP)T zTal7{5zpLs_{CrNSzwAWm8UHF9bx#AKWlvRqy{#&QlpB)#|x@1y67L(IDSZ>^Bg<1 z7;)mxADf|}mj;Y1Rp9`%iA2kmeXoD>tDy)CYZi|g;e$d4S?L3M~_K! z4@R3>Bv*!#-oa14T{w64@b2A5`sC-=zx*SxWlNuZ`TCczq4<5iuTZ0a{MlHK6K%_U zlQ?lA+C+spI^IGknrL=Ggw;CXW9eO!Q&AWkbh>#K(XC_y&vDb-*C0cI0f{dgx4VDH z2pEo2oNr7obhM{0%JNC8^c3hH#83zx3$P5N^&pcQK_}zC_S2uzCQuFV#`~#lNy8n( zE{j@Y;vkiCOFXEVy+H%9^nMZv(`Jl(z^&0XhopBIBL*&L%`Y(GsT^qL5*X5Qkj8}v zNt_(HSm5kX2#2C`Sq4~(@Z`+q^#CFp@uXr977AP0bN!{o)z?4Oj9yTiJ@8@!i0(P< zo5kZa-Rxn_rWM4*!Pme3#lvs@*6-u}E^ZSuQ#EBaQ)tQ$g=k?0-E9CJrRl?+ofdXj zM7&t&DFDq)f^81k%ZCJWBf$kf?hGn6^5BA_i5*)L=4pFR{gNY7hJ%O`NbVTW%@Adqp zH$FXg`fri+uk{A-*7Q?=(=*M6d_%SOoakNj+^UZO_Vus%CJ+Ykl4`EU!rBz(2R`t- z_nIO(X`!N)JZ-qMBd;AIX}=q&TYl8=3JUO9$hqXUmxLkXWQC4*2e`m71Tz z@ub{js1`J3#Iq}pJZR;-0T z|2Mz1@H2}QR;`xf#ukd3z<+^FV9k$V)1qTi+Vv*gQNyp-&{tf5=0=eHN^?HAK{Tq> z99MFD*f?Xn(P+K&2ccv%W+8|N=-E%YiwGUhMFpUL8-T`35ypW7PoK)i$*?1oIu^xq zf_f*7SRMn;yMOOs^Y@oGA0F^)VTZ4L>4&<$U0Oc9gr5lhoj-aT{m)s!CRo45#^II{XWa*%`ctlbb$;gI>9bu~950 zup*}xTGRcw8X0~pv>jJ*CsH(D%%*#Q5$|zDQwE+ik9?^&rW-xgX-iP(1jiww$({!p z6-59;N50TuZ!mIru)p!r@B0@nS^}mVf`xFq?)@89xo(^F+T!Hadw<#M#1(*Rul+UN zDE^h)0N#3cHgJ=%bWwkj^xUe|PS~ow6z1eat{kin4&+P0c9GF+32$Ep&a)4BH=21WE%AntjfBPa>QiaTXW`(UV{=?N7`_E_Z!zf@k48l ze_-W5P#eIT@8Tu0J>}GALO3@ECvtNla33eQvEFj|)vtcx@Y}!rhyGG9kT^H(0%No$ zpU)_50taWt+P%A+c-+O|DlZ-UJrNIrn?$H$lh>9!TNLYELA~sVNzLhQ3%5-g+EW^f zreB7<$%Zk8G>c^zb}-ZhJhWUr?1EzGz!OJPvku)*yD~9wL^}4$ws8m>I8;y`kiX)l zWq^jd*B`xR73(kK!vw$khu^b1NNOPBqGt5;DyEb4diTdWefV6e8fJF`=>cs}LNOKu zn=c#wpm}GP#TNOqb_KCJftg9o0kqi2k_@fQK6U87W)7@UsxlYJjr)>c^Czd{K_{9C z!|Wk`0_WzvM~Cm;dU&{pCoezyg-_`G8BIS5{-=NR<-@=F<|BP7II>c2BTseX5L8{@ zGH-E*sQON^!Vu4MIN#q%90Kbsf?Q`i&ttdO>EM|5U4Rr|vxkA>lRZr~4Wh|;)POH^ zl-qn_n{CwcEXd8D#YRQ|=uVWoqirTeYjc1aXXn-B2Lxy~D>Bo;Td7@KLq^-11pS34 zUz{`Av_sLo1WaGH6HF8O$n;V=BPw#HKdCI*1Uq)17fLy2IkN22q$zP2F&yunhX8)m zL;`r8(nd6}5a|Gb+_;>w@I#17=$!Si&RL}mCG*DP+`03IpZd%tX6%7FsKx6=;uyvX zBQ!3goAsE~ig^!faaQ1)A=!|9`@HUzr|VmB}YfLJ$L z*A)9*AmVoei*vL3%2&7v{3Ceag@u#cYx->GE9}l3_{$(q^exbx8)LAxG7CLm7k2}d z8Fg4mbP>RWWelS=e{KDNjn8M((HB#I5Ee?smltR#F`3aJVcm3$Z8jj38EZBwin0sF zY9=uecOdDt+?W_VMepCdr=-9AK0X8j!S8&ZcmI%qLR!uCd<`4#^*c#&-i4boE3gpP zH$&HF-L`S_CNi%u@E{YBzV}i67OOdobccp@_9q>wq1rUWheTpSXzu~#%fS{f-bcuM zxcQf9`nP0kjJ^MgF2`QUTFfQ`KeWkw)2Au&f&)5 z#Rs%vU2DdTqMN5K7QDGBPDfP(SsxisR57ZcHF|WsV&20n&ffjd%WwYhTJ&cof2s}O z&3C@DzuQ(iXuBorM(Y~)coaa~F3=*yRjZRL7QRQpSHJT4!~cg(pk|@_Uprm73ACRA z8NHO2#u@1wKO(t6@NOX8b5p<)v~416GZ7mFUSh(Hfw{<-jEe=YAuROKaHJ?<390dh zGkw}P(R47=q9q6O1HmX>iPEA+3Iu6Az^h$KgNqe&K67ASKlSDhzE_JU_9eWJ>|gzx zcd%ji@0E)rnMlxbVtume&lS3G@+Ew}zmt8--4T6>AFbJPPXtsQQzEIQ-p+$~|7Vk{ zuW-V!Zn=7%s(64355-ZN#Hepg8+syxhDP=#lRVPQd@UqD0ssD;hljWCJUZNZj23i# z*6crOs=X9!iAMU?gOR-TM9n2|RhG1yslq{T%ZQEv?`pw0?(8&6UB#WQV86^w;J4my@8MKTCAwlU_ZR6X-Gn5u;_H-Rje*&+f8rC` z>{0fqjVEg8bS?nIOrkH9mA8}m${h%fr^bYI%3B*KvoR56hMM?Nz{cf-;b9z({0Hs> zJUNp+;ioF}NYABMkRm{=0aBG4Du+#CR0pIdF(a*+s+wE;KpoFUwhq66{nc6tkJP{X z=4aMmN9EHE;ElJ}11~+UH)>r|yc`7`1r%pSu3DU2vGBb|(1j7xN%pwtF#K{`z3Crk zA~#w#vr+Rb7+kMy0t3cHfixfdSveNnA)tgj^?2l6K;IEMRFjQs04KN>f0~guM{7smh<>cls!=Ajqj{t)N`sl8@P#y(1h7WU{`<}c_dq{6{N6jb z3n*vI(Q^38-}s0(f8MsJeXarnJIen%Ig=mTwJqOS;KjErMSD+~8R;T}@r#XMa~8hBk`7>l@usDY2}wd4T3E3d{aqoG1){I% z$1+eeaUlBU!!w63{={pbw`^tlksN;NN3I_J;LjfWr-HX~pw8N`mdKhl+uQoSx2B41 z=YF65Z6j!XZX%3^9B`AsISnZlJn2z$MvJ>6b7@>m70I!P3&ff;)!6VZT}v61y)9bz{TJ3y+p z4HaPFYy}nS>X0x1uf6ita-(?e8^87+{nPt*ZXE96!J3b0S!qL$-397Q zrWR8n%~lni&pk}%DVZepxHtvI{M*FbZ~m!IzJB=r_dkHazWn0lF-5>R!qbG0(Ynb7 z4HR57jux2(E@}oc+PwC3L<(K z9q-^f4w)6;SJ_7E zeUZ(r;Gv1Ssns`WV)~k!MXPo_ooPWF*pRUfx<=GRN;v!ttV!>wdNp({Tl!@N7+2|$KaaxkW}Bi zV3ThsX-(+Brt=nd_&D!E=hGi)!p==i=Zt(KF5Z)O=IYaI5(?UnL z9Zj4;snEG0mWHETddFbXr}rUaOfTX?QkSpkjYf2Tw3z=2i$GQOEa}#kk0#Q@>mVA~ z6E^OM^@^H%y9Z^TNB-Qe|0nPL#Bn3aMRHr0HIw^CYH|>~(VjocSL&ASZBe z1iF%c5f@UvvQyZGY_h`p~&)p5dT`xC76QD zLrvVDk(!fuMhZWUQH4XAI6isCcRgk8(KZGt<3dg>AHc?j;r88!cz@C1@D6?|>>P+c zzj6O?pYN4alel=DIeh99W2UJxInE5)~w%r{mS9p56UKx4!E!mj@In0wFYh& zvyL$zeZ@J^hlrSND`n8tL!}7{-s+YEGwWrY&drEPIf-Dru478<&3lgz*YAIL`0*bd z#!UG>pTnEjOuTa)-$jU`i>VRf=La@E8d?$FDH25oxW1SyEUDu;gW5c_v25pnX(C$Y zDS;UZ5e6Y=?WXohffPDe9;U1`m62o0&AEIryS2@qhiXAiMNPa+MAz?lmXYkPSQAIp zZlPWXD%$B0GbS|Z%`BwqPl&Sa&IOkXOoV_wq~IwCU7;znQ-K6AW=mOY7+J@IAF$!$ zcRqN0xb%|0Y!t3hXQ8jvvZ-9PT2shdx=dmX{*-d~_YfaVq>0xeD&&dUah?loo31Ca zor1elH<=`+He14nkNDz6EBaA(XYW7YxBoAje(%BfX$XtY4ZvM|cUY8ju#LICG`Lj~}&hLH`-XY*qZM%Oz zjmEBfj8Axch_>)0y^CI-hv|oC0<+{DvJVlHz04-_r9ixISFYtAvUo{>sYwPbakdzE zojex`T&kUCV#1-;qLMWi4}bO@SU`klg;-JAW;KL#(n>yuhd4C05yYotLGWcTwv##y zBizsv7q6fB(vRt-ps)+Cckv0+^XQ259SYQUpc~71AGce$%g938S}t8Uci?7}<$Ro5 z4*2w2R}SBO`;Iu}!Q|O#tWn0?BrKY$SNmwk@EBC}BVBP!7rr=B@iMRJV_X@ZT6g2) z>0=8&>OUMc*2$Zkai}rxy)1IoX-g-F!7AjGWI^)Q|Ev#(>*&At?|pc-B!PA-1khACWn&s6$1CV#CO(v1=H$@f#LK{3+qjnT zoa0%?BlWRi4><=@o-jxpCyk5|u!pYDY_Wz1IDR1QaU5nv#I71UiKafoYZ1-n`J7-afMRLFu( z)RI_WL$D4_O+d3wO%kbc!W7&ru*BE9T)o_0Cb@FS=s);_KeY+Zj&y%kIYS3YKEdV> zx8||&xQLAkH!IwSvOb4Nt(r7s( z<7*0dS)|19?pD=&Yb=Fxz8gOuOZUTQ8e6Z~hQ?bi!2=>)V8pY{5tBeL z=gyx$oWVD~>?#&w7gT`?adrT%(3RLQ5>IsWL*~!A0nj{ zvXWz*168z25B6j^t~ReL?Q1sl?Ob5*xEzKte>HYEa2+7s&Y6(4@eZBU&~yF3O%>i) zxy=vpoTvNk75t5)yG)eG*#O^Pz(B&h0`#0HVtBo>+wsP14L6R@SQlXOhjMBS8) zavcPUH-Wr!@B}A< z%fO$5FU)COEcnK8PI%rK3YnhCL#71-a@H_cOPAu-^rCcdJ$SUI3(6 z)h^&6m|2`Xi_IUtGwbmqyGs~YbwC<`NV<@0*ih7dmvd}*kx-3sC0ch1F#jks9^)%H zYxX+F4x^&+w{eIulODeLsYCWBvGiejN-Q5z)LJxEe!fa5ydjNuurrnyU2z+(&EKu( zyZK{c4~AD4-hI%YL+8=pM`tf+6R3uupVY@{XlpI|iX|jbo3tFOLY-L!y#vl;cCg*~ zvvJ{(2$q?gWsAW6cL%_Qm7Y1)##vKzTH;$$|)p2P!H_LS5t{m+%n*K8y_<+yrQ% z0$8~31O@25x9YQGk(g?pLXH3nlo>0Dg4$0iOc$#%@a^tvl${&EF^THJboC`tO8BYE zC_FcA+~Ipv^YG%oj~@FEO4-;y@#haHr7j{Ou$y^S77K2<0d_>7Tl010I&FD*zu@H za?C4DmPHUSzICJi@t!r##ye!Oot$Ehe!joJtG=d|yt+?ndlW`uKghR`|vznVbZWOF0R-gFJ zb4N+$MnZEH7)tG7dfI7l5rXFy$DjXnLmMk0^Z8CO&2c!3Cpc#>@dPyF+VO~5zMAcE zbi+EOd=}EfPvcYFfB5Y?dg6!+m;hQS5Q68lZkFg|A8aE9V%#c1geo-Zvtlm)9bcG% zq_5GQYaC<6SHaL9N5*PIMZO##ivt=HFy@xEhdMP*zO`YOz4KOu~Q$*l{o&XTz^ULd#;dW-?N zfCt|T$PJ&}5yWN?vqm)J=irQAEx;d%=X(=5hI~fJJA!;q8P7%ifIN7vi+pg>iAMO4 zF(;OeC$7k?Imlm5pfi)jv1=6=^6Epxxzi{Dvzv%hbm=lDVy7X*5Q$osR= zyz|R3<=8VLj+qY;XdADP)tX?_(`mbG(bgLQVl2$U7cx-H{pN!Y58t`@S7-CrT6Fht z{`^&}cW4LunFRWaEmUDYf=J%Q(@Nd4flL+r!znifEF4eW@#Dncct~nB$JOrO8c!YZ zW8vn2BSL-uAZP6{s_D&!o_K!1D;^j0!2rI*O8H3(Bh)fDfqFz#^nxDDtNy1xbLH@d ze{s*gPE`ZfT50c-+b}MwxTBT>>Y(-`l)nz(7%Q5R!0B0Tq(U5gD%5YG#;4i7(x61w*uo1puHq)7Y;5ab|;W8@p02<8wbj@AsjY>yr6e?1i>>h z<+=+9jx0m3kx?o_i^qq*Y|Eez03FYDV|I5a;wo<5c*YEOd0d$hY- zjdJe$S))xj*If2z-UWoO-28#&IJ0oxXk0<(7)uie0+|R$)JBUqoi~C&4hT2?as(VV z3lH)3h6_*n58jNeaC24k-xtQ*D9m9)eNxpIe(3VykH32#n|Hh|1Dz7@>FoXV5p#O7 z{mefFZq>sfWE&U4sVL(LzU&*0Ev2>Y{o6WDb3NRT&K#x6jBP|?ED2GhJR!h==BKy+ z^{pp|&%P-qKt-V=x0$soU_(86>C{Sqk|aPEi$abl^2bW9H*!BX8;v*AHM>4nI_VAB zzni6p7ie!7Yia@XWDg&*0Mz67r+};NY6cb5(R?%LLwuMcEV-b>qCy}qGA#F2Kk#9e zVJ~pUNHQGyg)f5-sLFO0NBw6X!G(iQYI7pIXF4t%wE+RCGRI$e`I@BM2rA#Tlp#0O zliVOZ#Kh*top%KJ$Pb8s)&~i&2=eaHW8lsqp57A(S}*r%thvy0%=?FO!Zch^0_Mwl0V z-r?m~abA+w2Y*o8xWX-Twy7u1FdAC~)W!jN$~jK9skiZi2n3}VW9iA8&W^d{LXp|g zN<~^2J~?yl@U0vCf@S+;bm;%8)O9F6ynFp{?X^#0eSCCy_~8EG!F|3LwIWX1s<35m z2VE)pvvY*wC=}-$2=bh>?B|%f+>|pP>NUnr(@qm!6nZmikQyue@~85H)6{vSdi0UXEy6oHAnwDSVzj7o&k*$ih8qygZI{yxWjrUe zsUQY2ojG*kG~esTad?2C`RpYbm@<#FGTRib5$<=cHl>c{a<5nAJ@qMPv!EUzHk=+#0!@@Ah6*@mT$a|M&6lx za`Fc5%n;Bupko%nCFm@6;c4c;-S~(NgCjNz^^VE>)HdlLiAcDs@7}znXRhjJLD74j zpHY4brX?o;i6H z;F5Mx%N7D8OrCjLjZFy%zO>D|fA8bx!T4UsZ{K=^FC)F`9{P%QSvn@Ag;)o4nvN;v zQ*s-eS|`rx{bY7Gf_E`(%%If2)fT4A61OEtWbi%)94~GF`5h#Pwp(4fzRTC~U8yy5)H%Woa- z+`fgE_xY701ae-E1|gUP7@<3OdWkt2hZvd z#QN@&bBFgn;BO@p&{f7vj}DyZxM_T4*ccLa4WGhhImZ(jW&HXj2^WX;I14BQ-@bnS zaQoH`eM+C>#u=sc)ajWG8=X;-7?D)ic#r3SOmJApL(#c1AN+6@_sTZ6V_i00^B6Xo z>+n%)j<4}t2WZEdAMl_*KF%Kg`1X^-=ibE2632@2>p>Gy0c>jva7k{OnsO7e^tdib zM@g(=k0VCYVo}HuwZ$*ZmXfqDsx$xWfBfI=>`Cnku|O>ZGK>#*gqNH8@JDb~-{yCv z74z}azfI*-6ZDRp>($_T!NO*t9%tELvBVRwkakv`Hkl+k?9^LAli0Rdz@8i)-nj=G zdkMon5epF7Drh^T-w5(K^Zmm19X&^Dr( zx@Hu#`B6-hWZ%ceF2>Bp(8gV3CX9`ncpt+Yb9Oxcp+u$ejoipc)g}M#$ST{4{IB^bVZMa#FXQKvYC5PVv>c)Z z`k%LCb}El&*W9wtcI32SgW+O6W866AobPrH7$(&J8A*_54* z2H3z(QaO&87@26*)D)}gRfiKrI!R&zYNCCpz!!8OL%UU<`Dg#b|2Cd?iCRd)tO zr(v;B{f1seHQM+ z?}ukELeXZs90hEfAJInqIt4EBSuRR!6wQU@@v^@xeMw*FZ<_OHUzGT| zm>4(K@dylS_K%gQ$;H^XN9Ky^AM@c+ZDk9(FhVx9#JLiZLNSz!&z;O6N~ZCs~+u+GLXWi12hfkI_jxW{1r) zz@NEz;cx+q+5KDivQu9LWAW-bBV*Zyf*%y&zC7*(^1XpvT;dC)`Emp4*!Z>D7C&7; z{GwrWTNJqv)Du;79Jru*Vyu}m>)8D9E;B#WzzyI%9KL%8N#e_84aM?O?(RLjCCPz-f9HL?`_DIsEvGYwmvA?c<8uu+7w?|C zqPIM89sLj=7U5Sn#fQwohTpl@+Fsk^;|-A`!epo;qd~mE>O8W+^Bp|EFB6U$HvFW6bE2sW1Iby$rCP9sTxN`5Pbu z+d|58rqyh2xHW2K>ItMO(@SE_Tb$0lJ{=_?{o-q2DD6Y^TzXv*r@jz;X2EifA8k~@ zvUly`LGKYPem?p7Rs4DbzX-PpZ{lk_!p4b3)zDBSlf+IuVRM^K+A(vR4$?8F!7pvn zWfH{H(iAq~Y{9v3^@>3EZ{J4EF=;rno}93NJj5o88$nud!

Tv>AkqLtHlt@r<*r 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 TnA^xN00000NkvXXu0mjf9DTOg literal 0 HcmV?d00001 diff --git a/Tests/AdaEngineTests/Render3DFogTests.swift b/Tests/AdaEngineTests/Render3DFogTests.swift new file mode 100644 index 000000000..33261b72c --- /dev/null +++ b/Tests/AdaEngineTests/Render3DFogTests.swift @@ -0,0 +1,240 @@ +#if os(macOS) +import AdaECS +@_spi(Internal) import AdaRender +import AdaScene +import AdaUtils +import Foundation +import Math +import Testing + +@testable import AdaCorePipelines + +/// Fog settings that need no GPU. +@Suite("3D fog settings") +struct Render3DFogSettingsTests { + @Test func defaultsAreOff() { + let fog = Fog3D() + #expect(!fog.isEnabled && !fog.changesImage) + #expect(Environment3D().fog == fog) + } + + @Test func olderPayloadsDecodeWithoutFog() throws { + let environment = try JSONDecoder().decode(Environment3D.self, from: Data("{}".utf8)) + #expect(environment.fog == Fog3D()) + let partial = try JSONDecoder().decode(Fog3D.self, from: Data(#"{"isEnabled":true,"density":0.1}"#.utf8)) + #expect(partial.isEnabled && partial.density == 0.1 && partial.maximumOpacity == Fog3D().maximumOpacity) + } + + @Test func settingsRoundTripAndFlatAccessors() throws { + var environment = Environment3D() + environment.fogEnabled = true + environment.fogDensity = 0.3 + environment.fog.heightFalloff = 0.5 + let decoded = try JSONDecoder().decode(Environment3D.self, from: JSONEncoder().encode(environment)) + #expect(decoded.fog == environment.fog) + #expect(decoded.fogEnabled && decoded.fogDensity == 0.3) + } + + @Test func wildValuesAreClamped() { + let wild = Fog3D(isEnabled: true, density: .nan, heightFalloff: -4, startDistance: -1, maximumOpacity: 7, skyInfluence: 2, sunExponent: 0).sanitized + #expect(wild.density == Fog3D().density) + #expect(wild.heightFalloff == 0 && wild.startDistance == 0) + #expect(wild.maximumOpacity == 1 && wild.skyInfluence == 1 && wild.sunExponent == 1) + } + + @Test func volumetricDefaultsAreOffAndOlderPayloadsDecode() throws { + #expect(!VolumetricFog3D().isEnabled && !Fog3D(isEnabled: true).volumetricActive) + let decoded = try JSONDecoder().decode(Fog3D.self, from: Data(#"{"isEnabled":true}"#.utf8)) + #expect(decoded.volumetric == VolumetricFog3D()) + let partial = try JSONDecoder().decode(Fog3D.self, from: Data(#"{"volumetric":{"isEnabled":true,"steps":40}}"#.utf8)) + #expect(partial.volumetric.isEnabled && partial.volumetric.steps == 40) + } + + @Test func volumetricRequiresFogAndClampsValues() { + var fog = Fog3D(isEnabled: false, volumetric: VolumetricFog3D(isEnabled: true)) + #expect(!fog.volumetricActive, "Light shafts need the fog medium.") + fog.isEnabled = true + #expect(fog.volumetricActive) + fog.volumetric.intensity = 0 + #expect(!fog.volumetricActive) + let wild = VolumetricFog3D(isEnabled: true, intensity: .nan, anisotropy: 5, steps: 1000, maxDistance: -3).sanitized + #expect(wild.intensity == VolumetricFog3D().intensity && wild.anisotropy == 0.95) + #expect(wild.steps == 64 && wild.maxDistance == 1) + #expect(VolumetricFog3D(steps: -2).sanitized.steps == 4) + } + + @Test func settingsThatCannotChangeTheImageAreNotCountedAsAnEffect() { + #expect(!Fog3D(isEnabled: false).changesImage) + #expect(!Fog3D(isEnabled: true, density: 0).changesImage) + #expect(!Fog3D(isEnabled: true, maximumOpacity: 0).changesImage) + #expect(Fog3D(isEnabled: true).changesImage) + } +} + +extension Render3DGPUTests { + /// Renders fog through the real composite pass. + /// + /// Run alone: `ADAENGINE_GOLDEN_METAL=1 swift test --filter Render3DGPUTests/Fog`. + @Suite("3D fog on GPU") + @MainActor + struct Fog { + private static let fogColor = Color(red: 1, green: 0, blue: 0) + /// A pixel on the first sphere's lit side, 3.4 units from the camera. + private static let sphere = (x: 100, y: 215) + + /// How far the pixel is from the fog color, as the red channel's lead over blue. + private static func redness(_ image: Image) -> Float { + let pixel = image.getPixel(x: sphere.x, y: sphere.y) + return pixel.red - pixel.blue + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func fogOutOfReachChangesNothing() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let baseline = try await scene.render(.none) + let unreachable: [Fog3D] = [ + Fog3D(isEnabled: false, color: Self.fogColor, density: 2), + Fog3D(isEnabled: true, color: Self.fogColor, density: 2, startDistance: 50), + Fog3D(isEnabled: true, color: Self.fogColor, density: 2, heightFalloff: 10, baseHeight: -50), + ] + for fog in unreachable { + try scene.configure { $0.fog = fog } + let image = try await scene.render(.none) + #expect(GoldenFrame.compare(baseline, image, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance)) + } + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func denserFogPullsSurfacesTowardTheFogColor() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let clear = Self.redness(try await scene.render(.none)) + try scene.configure { $0.fog = Fog3D(isEnabled: true, color: Self.fogColor, density: 0.1) } + let light = Self.redness(try await scene.render(.none)) + try scene.configure { $0.fog.density = 1 } + let dense = Self.redness(try await scene.render(.none)) + #expect(light > clear && dense > light, "Fog must tint monotonically with density (\(clear), \(light), \(dense)).") + try scene.configure { $0.fog.maximumOpacity = 0.2 } + let capped = Self.redness(try await scene.render(.none)) + #expect(capped < dense, "A lower maximum opacity must leave more of the surface (\(capped) vs \(dense)).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func heightFogThinsAboveItsBaseHeight() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + // The camera and spheres sit near y = 0.5, so a base at 0.5 gives full density and one far above gives none. + try scene.configure { $0.fog = Fog3D(isEnabled: true, color: Self.fogColor, density: 0.5, heightFalloff: 1, baseHeight: 0.5) } + let inside = Self.redness(try await scene.render(.none)) + try scene.configure { $0.fog.baseHeight = -8 } + let above = Self.redness(try await scene.render(.none)) + #expect(inside > above, "Fog must be thinner when the camera is above its base (\(inside) vs \(above)).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func fogMatchesGoldenFrames() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene(farMarker: true) + try scene.configure { + $0.fog = Fog3D( + isEnabled: true, + color: Color(red: 0.62, green: 0.72, blue: 0.82), + density: 0.12, + heightFalloff: 0.3, + baseHeight: 0, + startDistance: 1, + maximumOpacity: 0.9, + skyInfluence: 0.5, + sunInscattering: 0.4, + sunExponent: 6 + ) + } + switch try GoldenFrame.check(try await scene.render(.none), name: "pbr-spheres-fog") { + case .recorded, .matched: + break + case .missingReference(let actual): + Issue.record("No golden frame for pbr-spheres-fog; record with ADAENGINE_GOLDEN_RECORD=1. Actual: \(actual.path)") + case let .mismatched(comparison, actual, difference): + Issue.record("pbr-spheres-fog differs in \(comparison.differingPixels) pixels. Actual: \(actual.path), difference: \(difference.path)") + } + } + + private static func meanLuminance(_ image: Image) -> Float { + var sum: Float = 0 + for y in stride(from: 0, to: 480, by: 4) { + for x in stride(from: 0, to: 640, by: 4) { + let pixel = image.getPixel(x: x, y: y) + sum += (pixel.red + pixel.green + pixel.blue) / 3 + } + } + return sum / Float(120 * 160) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func lightShaftsAddSunLightAndReleaseTheirTexture() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene(farMarker: true) + let renderWorld = try #require(scene.app.getSubworldBuilder(by: .renderWorld)?.main) + func shafts() throws -> VolumetricFog3DViews { try #require(renderWorld.getResource(VolumetricFog3DViews.self)) } + let fog = Fog3D(isEnabled: true, color: Color(red: 0.2, green: 0.2, blue: 0.2), density: 0.15, maximumOpacity: 0.6) + try scene.configure { $0.fog = fog } + let plain = try await scene.render(.none) + #expect(try shafts().output(view: scene.camera.id) == nil) + try scene.configure { $0.fog.volumetric = VolumetricFog3D(isEnabled: true, intensity: 4, anisotropy: 0, steps: 24, maxDistance: 20) } + let lit = try await scene.render(.none) + #expect(try shafts().output(view: scene.camera.id) != nil) + #expect(Self.meanLuminance(lit) > Self.meanLuminance(plain), "Light shafts must add sun light to the fog.") + try scene.configure { $0.fog.volumetric.isEnabled = false } + let released = try await scene.render(.none) + #expect(try shafts().output(view: scene.camera.id) == nil, "Turning the effect off must release the texture.") + #expect(GoldenFrame.compare(plain, released, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance)) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func shadowsCarveTheLightShafts() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene(farMarker: true) + let fog = Fog3D( + isEnabled: true, + color: Color(red: 0.2, green: 0.2, blue: 0.2), + density: 0.15, + maximumOpacity: 0.6, + volumetric: VolumetricFog3D(isEnabled: true, intensity: 4, anisotropy: 0, steps: 32, maxDistance: 20) + ) + try scene.configure { $0.fog = fog; $0.shadowsEnabled = true } + let shadowed = try await scene.render(.none) + try scene.configure { $0.shadowsEnabled = false } + let unshadowed = try await scene.render(.none) + #expect(Self.meanLuminance(unshadowed) > Self.meanLuminance(shadowed), "Unshadowed shafts must be brighter than shadowed ones.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func lightShaftsMatchGoldenFrames() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene(farMarker: true) + try scene.configure { + $0.fog = Fog3D( + isEnabled: true, + color: Color(red: 0.4, green: 0.45, blue: 0.5), + density: 0.1, + maximumOpacity: 0.7, + volumetric: VolumetricFog3D(isEnabled: true, intensity: 3, anisotropy: 0.3, steps: 32, maxDistance: 25) + ) + } + switch try GoldenFrame.check(try await scene.render(.none), name: "pbr-spheres-light-shafts") { + case .recorded, .matched: + break + case .missingReference(let actual): + Issue.record("No golden frame for pbr-spheres-light-shafts; record with ADAENGINE_GOLDEN_RECORD=1. Actual: \(actual.path)") + case let .mismatched(comparison, actual, difference): + Issue.record("pbr-spheres-light-shafts differs in \(comparison.differingPixels) pixels. Actual: \(actual.path), difference: \(difference.path)") + } + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func skyInfluenceControlsFogOverTheSky() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + func sky(_ image: Image) -> Float { image.getPixel(x: 10, y: 10).red - image.getPixel(x: 10, y: 10).blue } + try scene.configure { $0.fog = Fog3D(isEnabled: true, color: Self.fogColor, density: 0.5) } + let untouched = sky(try await scene.render(.none)) + try scene.configure { $0.fog.skyInfluence = 1 } + let fogged = sky(try await scene.render(.none)) + #expect(fogged > untouched, "Sky influence must tint the sky (\(untouched) vs \(fogged)).") + } + } +} +#endif diff --git a/Tests/AdaEngineTests/Render3DLightTilesTests.swift b/Tests/AdaEngineTests/Render3DLightTilesTests.swift new file mode 100644 index 000000000..e59065017 --- /dev/null +++ b/Tests/AdaEngineTests/Render3DLightTilesTests.swift @@ -0,0 +1,120 @@ +#if os(macOS) +import AdaECS +@_spi(Internal) import AdaRender +import AdaScene +import AdaUtils +import Foundation +import Math +import Testing + +@testable import AdaCorePipelines + +/// Tile bounds for local lights; no GPU needed. +@Suite("3D light tiles") +struct Render3DLightTilesMathTests { + private static let width = 640, height = 480 + private static let layout = LightTiles3DMath.layout(width: width, height: height) + private static let viewport = Rect(x: 0, y: 0, width: Float(width), height: Float(height)) + private static let projection = Transform3D.perspective(fieldOfView: .degrees(60), aspectRatio: Float(width) / Float(height), zNear: 0.1, zFar: 100) + + private static func masks(_ lights: [(center: Vector3, range: Float)]) -> [UInt32] { + LightTiles3DMath.masks(spheres: lights, projection: projection, viewport: viewport, layout: layout) + } + + private static func tile(x: Int, y: Int) -> Int { + (y / layout.tileSize) * layout.columns + x / layout.tileSize + } + + @Test func layoutKeepsTileCountWithinTheMaskBuffer() { + let full = LightTiles3DMath.layout(width: 1920, height: 1080) + #expect(full == .init(tileSize: 32, columns: 60, rows: 34)) + for (width, height) in [(3840, 2160), (7680, 4320), (1, 1), (100_000, 10)] { + let layout = LightTiles3DMath.layout(width: width, height: height) + #expect(layout.count <= LightTiles3DMath.maximumTiles && layout.columns * layout.tileSize >= width && layout.rows * layout.tileSize >= height) + } + } + + @Test func allLightsSetsTheLowBits() { + #expect(LightTiles3DMath.allLights(0) == 0) + #expect(LightTiles3DMath.allLights(3) == 0b111) + #expect(LightTiles3DMath.allLights(32) == UInt32.max) + #expect(LightTiles3DMath.allLights(99) == UInt32.max) + } + + @Test func aSmallLightAheadReachesOnlyTilesAroundItsProjection() { + let masks = Self.masks([(center: [0, 0, 10], range: 1)]) + #expect(masks[Self.tile(x: 320, y: 240)] == 1, "The tile under the light must list it.") + #expect(masks[Self.tile(x: 5, y: 5)] == 0 && masks[Self.tile(x: 630, y: 470)] == 0, "Far corners cannot be reached.") + #expect(masks.filter { $0 != 0 }.count < Self.layout.count / 4) + } + + @Test func lightsBehindTheCameraOrOffToTheSideReachNothing() { + let behind = Self.masks([(center: [0, 0, -10], range: 2)]) + #expect(behind.allSatisfy { $0 == 0 }) + let aside = Self.masks([(center: [100, 0, 10], range: 1)]) + #expect(aside.allSatisfy { $0 == 0 }) + } + + @Test func aLightReachingTheCameraPlaneCoversEveryTile() { + let masks = Self.masks([(center: [0, 0, 1], range: 3)]) + #expect(masks.allSatisfy { $0 == 1 }) + } + + @Test func eachLightGetsItsOwnBit() { + let masks = Self.masks([(center: [-4, 0, 10], range: 1), (center: [4, 0, 10], range: 1)]) + #expect(masks[Self.tile(x: 100, y: 240)] & 1 != 0 && masks[Self.tile(x: 100, y: 240)] & 2 == 0) + #expect(masks[Self.tile(x: 540, y: 240)] & 2 != 0 && masks[Self.tile(x: 540, y: 240)] & 1 == 0) + } + + @Test func lightsBeyondThirtyTwoAreIgnored() { + let lights = (0..<40).map { _ in (center: Vector3(0, 0, 10), range: Float(1)) } + let masks = Self.masks(lights) + #expect(masks[Self.tile(x: 320, y: 240)] == UInt32.max) + } + + @Test func aViewportOffsetMovesTheTiles() { + let offset = Rect(x: 320, y: 0, width: 320, height: 480) + let masks = LightTiles3DMath.masks(spheres: [(center: [0, 0, 10], range: 0.5)], projection: Self.projection, viewport: offset, layout: Self.layout) + #expect(masks[Self.tile(x: 480, y: 240)] == 1 && masks[Self.tile(x: 100, y: 240)] == 0) + } + + @Test func tilingDefaultsOnAndOlderPayloadsDecode() throws { + #expect(Environment3D().tiledLocalLights) + #expect(try JSONDecoder().decode(Environment3D.self, from: Data("{}".utf8)).tiledLocalLights) + var environment = Environment3D() + environment.tiledLocalLights = false + #expect(try !JSONDecoder().decode(Environment3D.self, from: JSONEncoder().encode(environment)).tiledLocalLights) + } +} + +extension Render3DGPUTests { + /// Run alone: `ADAENGINE_GOLDEN_METAL=1 swift test --filter Render3DGPUTests/LightTiles`. + @Suite("3D light tiles on GPU") + @MainActor + struct LightTiles { + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func tiledLightingMatchesTheFlatLoop() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + for index in 0..<10 { + let side: Float = index % 2 == 0 ? -1 : 1 + scene.app.main.spawn("Fill \(index)") { + PointLightComponent(radiance: [0.4 + 0.06 * Float(index), 0.6, 1 - 0.07 * Float(index)], intensity: 6, castShadows: false, range: 2.2) + Transform(position: [side * Float(index) * 0.45, 0.4 + Float(index % 3) * 0.3, Float(index) * 0.2 - 0.6]) + } + } + scene.app.main.spawn("Shadowed") { + PointLightComponent(radiance: [1, 0.8, 0.6], intensity: 8, castShadows: true, range: 4) + Transform(position: [0, 1.5, -1]) + } + try scene.configure { $0.tiledLocalLights = false } + let flat = try await scene.render(.none) + try scene.configure { $0.tiledLocalLights = true } + let tiled = try await scene.render(.none) + #expect( + GoldenFrame.compare(flat, tiled, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance), + "Tile masks are conservative, so they must not change the lit image." + ) + } + } +} +#endif diff --git a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift new file mode 100644 index 000000000..48b64abd4 --- /dev/null +++ b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift @@ -0,0 +1,177 @@ +#if os(macOS) +import AdaECS +@_spi(Internal) import AdaRender +import AdaScene +import AdaTransform +import AdaUtils +import Foundation +import Math +import Testing + +@testable import AdaCorePipelines + +/// How material extension values become shader constants; no GPU needed. +@Suite("3D material extension constants") +struct Render3DMaterialExtensionConstantTests { + @Test func defaultsGiveTheFourPercentReflectanceAndIdentityTransforms() { + let uniform = PBR3DUniform(material: PBRMaterial(), descriptor: VertexDescriptor()) + #expect(abs(uniform.dielectric.x - 0.04) < 0.000001 && uniform.dielectric.x == uniform.dielectric.y) + for transform in [uniform.transform0, uniform.transform1, uniform.transform2, uniform.transform3, uniform.transform4] { + #expect(transform.matrix == Vector4(1, 0, 0, 1) && transform.offset == .zero) + } + let fallback = PBR3DUniform(material: nil, descriptor: VertexDescriptor()) + #expect(abs(fallback.dielectric.x - 0.04) < 0.000001) + } + + @Test func iorAndSpecularScaleTheDielectricReflectance() { + let material = PBRMaterial() + material.ior = 1 + #expect(PBR3DUniform.dielectricReflectance(material) == .zero) + material.ior = 3 + #expect(abs(PBR3DUniform.dielectricReflectance(material).x - 0.25) < 0.000001) + material.specularFactor = 0.5 + #expect(abs(PBR3DUniform.dielectricReflectance(material).x - 0.125) < 0.000001) + material.specularColorFactor = Vector3(1, 0.5, 0) + let tinted = PBR3DUniform.dielectricReflectance(material) + #expect(tinted.x > tinted.y && tinted.y > tinted.z && tinted.z == 0) + material.ior = 100 + material.specularFactor = 1 + material.specularColorFactor = Vector3(50, 50, 50) + #expect(PBR3DUniform.dielectricReflectance(material).x <= 1, "The tint cannot push reflectance beyond total reflection.") + } + + @Test func nonFiniteValuesFallBackToDefaults() { + let material = PBRMaterial() + material.ior = .nan + material.specularFactor = .infinity + material.specularColorFactor = Vector3(.nan, 1, 1) + let value = PBR3DUniform.dielectricReflectance(material) + #expect(value.x.isFinite && value.y.isFinite && value.z.isFinite) + } + + @Test func textureTransformFollowsTheGlTFMatrix() { + let rotated = PBR3DTextureTransform(PBRTextureTransform(offset: Vector2(0.5, 0.25), rotation: .pi / 2, scale: Vector2(2, 3))) + // uv' = [cos*sx*u + sin*sy*v, -sin*sx*u + cos*sy*v] + offset + #expect(abs(rotated.matrix.x) < 0.00001 && abs(rotated.matrix.y - 3) < 0.00001) + #expect(abs(rotated.matrix.z + 2) < 0.00001 && abs(rotated.matrix.w) < 0.00001) + #expect(rotated.offset == Vector4(0.5, 0.25, 0, 0)) + } + + @Test func materialEditsChangeTheRevisionAndTheSharedBuffer() { + let material = PBRMaterial() + let before = material.uniformRevision + material.textureTransforms[0] = PBRTextureTransform(scale: Vector2(2, 2)) + material.ior = 1.8 + #expect(material.uniformRevision > before) + let plain = PBR3DUniform(material: PBRMaterial(), descriptor: VertexDescriptor()) + let edited = PBR3DUniform(material: material, descriptor: VertexDescriptor()) + #expect(plain != edited && plain.bitPatterns != edited.bitPatterns) + #expect(plain.bitPatterns == PBR3DUniform(material: PBRMaterial(), descriptor: VertexDescriptor()).bitPatterns) + } +} + +extension Render3DGPUTests { + /// Run alone: `ADAENGINE_GOLDEN_METAL=1 swift test --filter Render3DGPUTests/MaterialExtensions`. + @Suite("3D material extensions on GPU") + @MainActor + struct MaterialExtensions { + private static func checker() -> Texture2D { + var image = Image(width: 8, height: 8, color: .white) + for y in 0..<8 { + for x in 0..<8 where (x / 2 + y / 2) % 2 == 0 { + image.setPixel(in: Point(Float(x), Float(y)), color: Color(red: 0.9, green: 0.1, blue: 0.1)) + } + } + return Texture2D(image: image) + } + + /// A large sphere in front of the camera, in the middle of the frame. + private static func makeScene(_ configure: (PBRMaterial) -> Void) async throws -> (scene: Render3DGPUTests.DebugViews.Scene, material: PBRMaterial) { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let material = PBRMaterial() + material.baseColorFactor = [0.5, 0.5, 0.5, 1] + material.metallicFactor = 0 + material.roughnessFactor = 0.2 + configure(material) + scene.app.main.spawn("Probe") { + Mesh3DComponent(mesh: Mesh.generateSphere(radius: 0.5, renderDevice: scene.device), materials: [material]) + Transform(position: [0, 0.5, -2.2]) + } + return (scene, material) + } + + private static func peakHighlight(_ image: Image) -> Color { + var peak = Color.black + var best: Float = 0 + for y in stride(from: 130, to: 350, by: 2) { + for x in stride(from: 170, to: 470, by: 2) { + let pixel = image.getPixel(x: x, y: y) + let value = pixel.red + pixel.green + pixel.blue + if value > best { + best = value + peak = pixel + } + } + } + return peak + } + + private static func matches(_ left: Image, _ right: Image) -> Bool { + GoldenFrame.compare(left, right, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func textureTransformsMoveScaleAndRotateTheTexture() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorTexture = Self.checker() } + let plain = try await scene.render(.none) + material.textureTransforms[0] = PBRTextureTransform(scale: Vector2(3, 3)) + let scaled = try await scene.render(.none) + material.textureTransforms[0] = PBRTextureTransform(offset: Vector2(0.3, 0.1)) + let shifted = try await scene.render(.none) + material.textureTransforms[0] = PBRTextureTransform(rotation: 0.7) + let rotated = try await scene.render(.none) + for (name, image) in [("scale", scaled), ("offset", shifted), ("rotation", rotated)] { + #expect(!Self.matches(plain, image), "A \\(name) transform must change how the texture appears.") + } + material.textureTransforms[0] = PBRTextureTransform(rotation: 2 * .pi) + #expect(Self.matches(plain, try await scene.render(.none)), "A full turn must leave the texture where it was.") + material.textureTransforms[0] = .identity + #expect(Self.matches(plain, try await scene.render(.none)), "Resetting to the identity must restore the frame.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aHigherIORBrightensTheHighlightAndZeroSpecularRemovesIt() async throws { + let (scene, material) = try await Self.makeScene { _ in } + let standard = Self.peakHighlight(try await scene.render(.none)) + material.ior = 3 + let strong = Self.peakHighlight(try await scene.render(.none)) + material.ior = 1.5 + material.specularFactor = 0 + let none = Self.peakHighlight(try await scene.render(.none)) + func total(_ color: Color) -> Float { color.red + color.green + color.blue } + #expect(total(strong) > total(standard), "An IOR of 3 reflects more than 1.5 (\\(total(strong)) vs \\(total(standard))).") + #expect(total(none) < total(standard), "No specular strength must dim the highlight (\\(total(none)) vs \\(total(standard))).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func specularColorTintsTheHighlight() async throws { + let (scene, material) = try await Self.makeScene { $0.ior = 2.5 } + let neutral = Self.peakHighlight(try await scene.render(.none)) + material.specularColorFactor = Vector3(1, 0, 0) + let red = Self.peakHighlight(try await scene.render(.none)) + #expect(red.red - red.blue > neutral.red - neutral.blue, "A red specular tint must shift the highlight toward red.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func defaultExtensionValuesChangeNothing() async throws { + let (scene, material) = try await Self.makeScene { _ in } + let baseline = try await scene.render(.none) + material.ior = 1.5 + material.specularFactor = 1 + material.specularColorFactor = .one + material.textureTransforms = Array(repeating: .identity, count: 5) + #expect(Self.matches(baseline, try await scene.render(.none))) + } + } +} +#endif diff --git a/Tests/AdaEngineTests/Render3DPerformanceBenchmarkTests.swift b/Tests/AdaEngineTests/Render3DPerformanceBenchmarkTests.swift index 8e604d60b..f0f85e4be 100644 --- a/Tests/AdaEngineTests/Render3DPerformanceBenchmarkTests.swift +++ b/Tests/AdaEngineTests/Render3DPerformanceBenchmarkTests.swift @@ -33,6 +33,8 @@ extension Render3DGPUTests { var apply: (inout Environment3D) -> Void var temporalScale: Float? var extraLights = true + /// Additional unshadowed point lights spread over the grid, on top of the default lights. + var manyLights = 0 } /// Spheres per side of the grid (400 spheres by default). `ADAENGINE_PERF_GRID=70` stresses the CPU with 4,900. @@ -44,7 +46,7 @@ extension Render3DGPUTests { } /// A grid of spheres with eight materials, a ground, a sun with cascaded shadows and a ring of local lights. - private static func makeScene(size: SizeInt, temporalScale: Float?, localLights: Bool) async throws -> Render3DGPUTests.DebugViews.Scene { + private static func makeScene(size: SizeInt, temporalScale: Float?, localLights: Bool, manyLights: Int = 0) async throws -> Render3DGPUTests.DebugViews.Scene { unsafe RenderEngine.configurations.preferredBackend = .metal try RenderEngine.setupRenderEngine() try #require(unsafe RenderEngine.shared.type == .metal) @@ -106,6 +108,13 @@ extension Render3DGPUTests { Mesh3DComponent(mesh: Mesh.generateCube(size: [2 * Float(grid), 0.1, 2 * Float(grid)], renderDevice: device), materials: [ground]) Transform(position: [0, -0.05, Float(grid) * 0.4]) } + for index in 0.. Result { - let scene = try await makeScene(size: size, temporalScale: configuration.temporalScale, localLights: configuration.extraLights) + let scene = try await makeScene(size: size, temporalScale: configuration.temporalScale, localLights: configuration.extraLights, manyLights: configuration.manyLights) try scene.configure(configuration.apply) let metrics = Render3DPerformanceMetrics(warmupSamples: 0, synchronizesPasses: true) let renderWorld = try #require(scene.app.getSubworldBuilder(by: .renderWorld)?.main) diff --git a/Tests/AdaEngineTests/Render3DShadowFaceCacheTests.swift b/Tests/AdaEngineTests/Render3DShadowFaceCacheTests.swift new file mode 100644 index 000000000..f9f807b54 --- /dev/null +++ b/Tests/AdaEngineTests/Render3DShadowFaceCacheTests.swift @@ -0,0 +1,158 @@ +#if os(macOS) +import AdaECS +@_spi(Internal) import AdaRender +import AdaScene +import AdaTransform +import AdaUtils +import Foundation +import Math +import Testing + +@testable import AdaCorePipelines + +@Suite("3D local shadow face cache settings") +struct Render3DShadowFaceCacheSettingsTests { + @Test func reuseDefaultsOnAndOlderPayloadsDecode() throws { + #expect(LocalShadowSettings3D().reusesStaticFaces) + let decoded = try JSONDecoder().decode(LocalShadowSettings3D.self, from: Data(#"{"isEnabled":true,"maximumLights":2}"#.utf8)) + #expect(decoded.reusesStaticFaces && decoded.maximumLights == 2) + let off = LocalShadowSettings3D(reusesStaticFaces: false) + #expect(try !JSONDecoder().decode(LocalShadowSettings3D.self, from: JSONEncoder().encode(off)).reusesStaticFaces) + } + + @Test func digestsDependOnEveryWord() { + var first = FrameDigest3D() + var second = FrameDigest3D() + first.combine(Transform3D.identity) + second.combine(Transform3D.identity) + #expect(first.value == second.value) + second.combine(1 as Float) + #expect(first.value != second.value) + var moved = FrameDigest3D() + moved.combine(Vector4(0, 0, 0.001, 1)) + #expect(moved.value != FrameDigest3D().value) + } +} + +extension Render3DGPUTests { + /// Run alone: `ADAENGINE_GOLDEN_METAL=1 swift test --filter Render3DGPUTests/ShadowFaceCache`. + @Suite("3D local shadow face cache on GPU") + @MainActor + struct ShadowFaceCache { + struct Fixture { + let scene: Render3DGPUTests.DebugViews.Scene + let caster: Entity + let light: Entity + let renderWorld: World + + var snapshot: Render3DLightStatistics.Snapshot? { + renderWorld.getResource(Render3DLightStatistics.self)?.snapshots.values.first + } + + func moveCaster(to position: Vector3) { + caster.components[Transform.self] = Transform(position: position) + } + } + + private static func makeFixture() async throws -> Fixture { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let caster = scene.app.main.spawn("Caster") { + Mesh3DComponent(mesh: Mesh.generateSphere(radius: 0.25, renderDevice: scene.device), materials: [PBRMaterial()]) + Transform(position: [0.2, 1.1, -0.4]) + } + let light = scene.app.main.spawn("Shadow Point") { + PointLightComponent(radiance: [1, 0.85, 0.7], intensity: 9, castShadows: true, range: 5) + Transform(position: [0, 1.6, -1.2]) + } + let renderWorld = try #require(scene.app.getSubworldBuilder(by: .renderWorld)?.main) + return Fixture(scene: scene, caster: caster, light: light, renderWorld: renderWorld) + } + + private static func matches(_ left: Image, _ right: Image) -> Bool { + GoldenFrame.compare(left, right, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aStaticSceneReusesEveryFaceAndLooksTheSame() async throws { + let fixture = try await Self.makeFixture() + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = false } + let uncached = try await fixture.scene.render(.none) + #expect(fixture.snapshot?.reusedShadowFaces == 0) + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = true } + _ = try await fixture.scene.render(.none, frames: 2) + let faces = try #require(fixture.snapshot).shadowFaces + #expect(faces == 6) + let cached = try await fixture.scene.render(.none) + #expect(fixture.snapshot?.reusedShadowFaces == faces, "Nothing changed, so every face must be kept.") + #expect(Self.matches(uncached, cached), "Reused faces must show exactly what a redraw shows.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func movingACasterRedrawsOnlyTheFacesItTouches() async throws { + let fixture = try await Self.makeFixture() + _ = try await fixture.scene.render(.none, frames: 3) + fixture.moveCaster(to: [0.2, 1.1, 0.6]) + // One frame: only the first frame after a change redraws, later frames reuse the new faces again. + let cached = try await fixture.scene.render(.none, frames: 1) + let snapshot = try #require(fixture.snapshot) + #expect(snapshot.reusedShadowFaces < snapshot.shadowFaces, "A moved caster must dirty a face.") + #expect(snapshot.reusedShadowFaces > 0, "Faces the caster never reaches must be kept, so the load path is exercised.") + #expect(snapshot.reusedShadowFaces > 0, "Faces the caster never touches must be kept, which exercises the partial redraw.") + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = false } + let redrawn = try await fixture.scene.render(.none) + #expect(Self.matches(redrawn, cached), "A partly reused atlas must equal a full redraw after the caster moved.") + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = true } + fixture.moveCaster(to: [0.2, 1.1, -0.4]) + let back = try await fixture.scene.render(.none, frames: 1) + fixture.moveCaster(to: [0.2, 1.1, -0.4]) + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = false } + let backRedrawn = try await fixture.scene.render(.none) + #expect(Self.matches(back, backRedrawn)) + } + + private func preparedPasses(_ fixture: Fixture) throws -> Int { + try #require(fixture.renderWorld.getResource(VisibleMesh3DLists.self)).preparedPassCount + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func anUnchangedSceneDoesNotRebuildFaceLists() async throws { + let fixture = try await Self.makeFixture() + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = false } + _ = try await fixture.scene.render(.none, frames: 3) + let before = try preparedPasses(fixture) + _ = try await fixture.scene.render(.none, frames: 1) + let rebuilding = try preparedPasses(fixture) - before + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = true } + _ = try await fixture.scene.render(.none, frames: 3) + let kept = try preparedPasses(fixture) + _ = try await fixture.scene.render(.none, frames: 1) + let reusing = try preparedPasses(fixture) - kept + #expect(rebuilding - reusing == 6, "The six local faces must not be prepared again (\(rebuilding) without reuse, \(reusing) with).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aNewCasterIsNoticedWithoutRebuildingEverything() async throws { + let fixture = try await Self.makeFixture() + _ = try await fixture.scene.render(.none, frames: 3) + fixture.scene.app.main.spawn("Late Caster") { + Mesh3DComponent(mesh: Mesh.generateSphere(radius: 0.3, renderDevice: fixture.scene.device), materials: [PBRMaterial()]) + Transform(position: [-0.4, 0.9, -0.6]) + } + let cached = try await fixture.scene.render(.none, frames: 1) + #expect(try #require(fixture.snapshot).reusedShadowFaces < 6, "A caster entering the light's range must dirty a face.") + try fixture.scene.configure { $0.localShadows.reusesStaticFaces = false } + let redrawn = try await fixture.scene.render(.none) + #expect(Self.matches(cached, redrawn)) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func movingTheLightRedrawsEveryFace() async throws { + let fixture = try await Self.makeFixture() + _ = try await fixture.scene.render(.none, frames: 3) + fixture.light.components[Transform.self] = Transform(position: [0.3, 1.6, -1.2]) + _ = try await fixture.scene.render(.none, frames: 1) + #expect(fixture.snapshot?.reusedShadowFaces == 0) + } + } +} +#endif From f2c2f93061aa633a08609273de90c1e2ebaaaa0e Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 02:04:04 +0300 Subject: [PATCH 02/11] 3D: add KHR_materials_clearcoat (factors) to PBR materials A clear lacquer layer over the base surface: a second GGX lobe with its own roughness on the geometric normal, a highlight for every light, energy-conserving dimming of the base layer, and an environment reflection with image-based lighting. The importer reads the clearcoat factors (validated to 0...1) and reports clearcoat textures as skipped. Adds the pbr-sphere-clearcoat golden frame (Metal) and records the cost and limits in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 28 ++++++- Sources/AdaAssets/GLTF/GLTFMaterialData.swift | 9 ++- .../GLTF/NativeGLTFLoader+Materials.swift | 11 ++- .../GLTF/NativeGLTFLoader+Schema.swift | 8 ++ Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 5 ++ .../Assets/Shaders/flat3d.glsl | 68 +++++++++++++--- .../CorePipelines/PBR3DUniforms.swift | 10 ++- Sources/AdaRender/Materials/PBRMaterial.swift | 4 + .../AdaScene/3D/ModelAsset3D+Materials.swift | 2 + .../GLTFMaterialExtensionTests.swift | 39 ++++++++-- .../GLTFMaterialExtensionMappingTests.swift | 5 +- .../metal/pbr-sphere-clearcoat.png | Bin 0 -> 112776 bytes .../Render3DMaterialExtensionTests.swift | 73 ++++++++++++++++++ 13 files changed, 241 insertions(+), 21 deletions(-) create mode 100644 Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-clearcoat.png diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 3dacf5db3..45b1d7078 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -295,7 +295,7 @@ Each item needs its own short requirements note before implementation. - [ ] Stage 1: motion blur (needs a velocity buffer that exists without temporal upscaling). - [ ] Stage 1: quality tiers for depth of field, bloom and auto-exposure. - [x] Stage 2: `KHR_texture_transform`, `KHR_materials_ior` and `KHR_materials_specular` factors (see "Stage 2, first slice"). -- [ ] Stage 2: clearcoat material extension. +- [x] Stage 2: `KHR_materials_clearcoat` factors (see "Stage 2, second slice"). - [x] Stage 2: parallax occlusion mapping from `PBRMaterial.heightTexture` (world-unit `heightScale`, per-pixel ray march in `flat3d.glsl` that derives the UV shift from screen-space derivatives so no tangents are needed; the height map is read with the base color sampler because Metal has no free sampler slot; depth, shadows and silhouettes are not displaced; no glTF import yet because no displacement extension is ratified). - [ ] Stage 2: sheen, anisotropy and `KHR_lights_punctual` import. - [x] Stage 2: unsupported-extension degradation policy and documented supported list (`NativeGLTFLoader.supportedExtensions`, `GLTFImportResult.warnings`). @@ -766,6 +766,32 @@ failure), `GLTFMaterialExtensionMappingTests` (importer to `PBRMaterial`), `Rend brightens and zero specular dims the highlight, a red tint shifts it, defaults change nothing). Not validated on WebGPU (no Swan/Dawn here). Still open in Stage 2: clearcoat, sheen, anisotropy, specular textures and `KHR_lights_punctual`. +### Stage 2, second slice: clearcoat (2026-10-19) + +`KHR_materials_clearcoat` adds a clear lacquer layer over the base surface (car paint, varnished wood, nails). +`PBRMaterial.clearcoatFactor` and `clearcoatRoughnessFactor` come from the glTF factors (0 to 1, validated on import) and +reach the shader through `PBR3DUniform.coat`. Following the glTF layering model, in `flat3d.glsl`: + +- the coat is a dielectric layer (reflectance 0.04) shaded with its own GGX lobe and roughness on the **geometric normal** + (the normal map belongs to the base layer); +- every light (the sun, extra directional lights and local lights, through `evaluateDirect`) adds the coat's highlight and + dims the base layer under it by `1 - strength * F(N.V)`, so energy is conserved; +- with image-based lighting the coat also reflects the environment along the geometric normal, using the BRDF + lookup at the coat's roughness, and dims the base IBL by the same transmission; +- with only the flat ambient, the ambient term is dimmed by the same transmission. + +A material with no coat takes one branch on `g_Clearcoat.x`. Measured against the merge commit without this change (1080p, Apple +M-class, release): `bare` geometry 1.77 to 1.82 ms and 2.18 to 2.26 ms with 32 lights, about 3 to 4 percent. For reference +the parallax merge from `main` had already moved `bare` geometry from 1.69 to 1.77 ms. + +Limits: the clearcoat, clearcoat roughness and clearcoat normal **textures are not read** (the importer reports them in +`GLTFImportResult.warnings`), because the main pass has no free texture slots on Metal. The screen-space reflection composite +reflects the procedural sky from the base layer's roughness only, so without image-based lighting the coat shows light +highlights but not a sky reflection. There is no clearcoat debug view and no WebGPU validation. Tests: +`GLTFMaterialExtensionTests` (import, ranges, texture warning), `Render3DMaterialExtensionConstantTests` (uniform), +`Render3DGPUTests/MaterialExtensions` (a smooth coat adds a highlight to rough paint, a rougher coat dims it, the highlight grows +with strength, turning the coat off restores the frame) and the `pbr-sphere-clearcoat` golden frame (Metal). + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift index 3c6a7acac..64037dfea 100644 --- a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift +++ b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift @@ -49,6 +49,9 @@ public extension GLTFImportResult { /// `KHR_materials_specular` strength and tint of the dielectric reflection. Specular textures are not read. public let specularFactor: Float public let specularColorFactor: Vector3 + /// `KHR_materials_clearcoat` strength and roughness. Clearcoat textures are not read. + public let clearcoatFactor: Float + public let clearcoatRoughnessFactor: Float public init( name: String?, @@ -71,7 +74,9 @@ public extension GLTFImportResult { textureTransforms: [TextureTransform] = Array(repeating: .identity, count: 5), ior: Float = 1.5, specularFactor: Float = 1, - specularColorFactor: Vector3 = .one + specularColorFactor: Vector3 = .one, + clearcoatFactor: Float = 0, + clearcoatRoughnessFactor: Float = 0 ) { self.name = name self.baseColorFactor = baseColorFactor @@ -94,6 +99,8 @@ public extension GLTFImportResult { self.ior = ior self.specularFactor = specularFactor self.specularColorFactor = specularColorFactor + self.clearcoatFactor = clearcoatFactor + self.clearcoatRoughnessFactor = clearcoatRoughnessFactor } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift index aba7cc486..ef60dc20c 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift @@ -85,6 +85,9 @@ extension NativeGLTFLoader { let specular = material.extensions?.KHR_materials_specular let specularFactor: Float = specular?.specularFactor ?? 1 let specularColor: [Float] = specular?.specularColorFactor ?? [1, 1, 1] + let coat = material.extensions?.KHR_materials_clearcoat + let clearcoatFactor: Float = coat?.clearcoatFactor ?? 0 + let clearcoatRoughness: Float = coat?.clearcoatRoughnessFactor ?? 0 let textures: [Int?] = [ pbr?.baseColorTexture?.index, pbr?.metallicRoughnessTexture?.index, @@ -96,9 +99,11 @@ extension NativeGLTFLoader { let texturesExist = textures.compactMap { $0 }.allSatisfy { $0 >= 0 && $0 < textureCount } let factorsAreValid = base.count == 4 && emissive.count == 3 && unitValues.allSatisfy { $0.isFinite && $0 >= 0 && $0 <= 1 } let scalarsAreValid = normalScale.isFinite && cutoff.isFinite && cutoff >= 0 && strength.isFinite && strength >= 0 + let coatIsValid = clearcoatFactor.isFinite && clearcoatFactor >= 0 && clearcoatFactor <= 1 + && clearcoatRoughness.isFinite && clearcoatRoughness >= 0 && clearcoatRoughness <= 1 let specularIsValid = ior.isFinite && ior >= 1 && ior <= 100 && specularFactor.isFinite && specularFactor >= 0 && specularColor.count == 3 && specularColor.allSatisfy { $0.isFinite && $0 >= 0 } - guard factorsAreValid, scalarsAreValid, specularIsValid, texturesExist, transformsAreValid, + guard factorsAreValid, scalarsAreValid, specularIsValid, coatIsValid, texturesExist, transformsAreValid, coordinates.allSatisfy({ $0 == 0 || $0 == 1 }), let alpha = GLTFImportResult.AlphaMode(rawValue: material.alphaMode ?? "OPAQUE") else { throw GLTFError.invalidMaterial(index) } @@ -123,7 +128,9 @@ extension NativeGLTFLoader { textureTransforms: transforms, ior: ior, specularFactor: specularFactor, - specularColorFactor: Vector3(specularColor[0], specularColor[1], specularColor[2]) + specularColorFactor: Vector3(specularColor[0], specularColor[1], specularColor[2]), + clearcoatFactor: clearcoatFactor, + clearcoatRoughnessFactor: clearcoatRoughness ) } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift index 42edb1932..ee0e622d3 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift @@ -132,7 +132,15 @@ extension NativeGLTFLoader { let specularTexture: TextureReference? let specularColorTexture: TextureReference? } + struct Clearcoat: Codable { + let clearcoatFactor: Float? + let clearcoatTexture: TextureReference? + let clearcoatRoughnessFactor: Float? + let clearcoatRoughnessTexture: TextureReference? + let clearcoatNormalTexture: TextureReference? + } let KHR_materials_emissive_strength: EmissiveStrength? + let KHR_materials_clearcoat: Clearcoat? let KHR_materials_ior: IOR? let KHR_materials_specular: Specular? } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 23fef83cc..29d34bb35 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -25,6 +25,7 @@ public struct NativeGLTFLoader: GLTFLoader { "KHR_texture_transform", "KHR_materials_ior", "KHR_materials_specular", + "KHR_materials_clearcoat", ] /// Optional content the importer cannot honor: unimplemented extensions the file lists as used, and the parts @@ -38,6 +39,10 @@ public struct NativeGLTFLoader: GLTFLoader { if let specular = material.extensions?.KHR_materials_specular, specular.specularTexture != nil || specular.specularColorTexture != nil { result.append("Material \(index) uses a KHR_materials_specular texture; only its factors were applied.") } + if let coat = material.extensions?.KHR_materials_clearcoat, + coat.clearcoatTexture != nil || coat.clearcoatRoughnessTexture != nil || coat.clearcoatNormalTexture != nil { + result.append("Material \(index) uses a KHR_materials_clearcoat texture; only its factors were applied.") + } } return result } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index 0844ebb39..3d824516f 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -144,6 +144,7 @@ layout (binding = 15) uniform PBR3DUniform { // matrix, then xy is the offset (glTF KHR_texture_transform). vec4 u_TextureTransforms[10]; vec4 u_Dielectric; // rgb: reflectance of non-metals at normal incidence (0.04 for the default IOR of 1.5) + vec4 u_Coat; // x clearcoat strength, y clearcoat roughness (KHR_materials_clearcoat) }; vec2 applyTextureTransform(vec2 uv, int slot) { @@ -250,6 +251,15 @@ vec3 fresnelSchlick(float cosine, vec3 reflectanceAtNormal) { return reflectanceAtNormal + (vec3(1.0) - reflectanceAtNormal) * pow(clamp(1.0 - cosine, 0.0, 1.0), 5.0); } +// Clearcoat layer: x strength, y roughness. It sits on the geometric normal, not on the normal map. Set once per fragment. +vec2 g_Clearcoat = vec2(0.0); +vec3 g_CoatNormal = vec3(0.0, 0.0, 1.0); + +// Share of the base layer's light that survives the coat at this view angle. +float coatTransmission(float normalView) { + return 1.0 - g_Clearcoat.x * fresnelSchlick(normalView, vec3(0.04)).x; +} + // UV shift from parallax occlusion mapping, applied to every material texture that uses the height map's UV set. vec2 g_ParallaxDelta = vec2(0.0); @@ -335,7 +345,17 @@ vec3 environmentLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float vec2 lutSize = vec2(textureSize(sampler2D(u_BRDF, u_IBLSampler), 0)); vec2 lutUV = clamp(vec2(ndotv, roughness), 0.5 / lutSize, vec2(1.0) - 0.5 / lutSize); vec2 lut = texture(sampler2D(u_BRDF, u_IBLSampler), lutUV).rg; - return (diffuseWeight * irradiance * baseColor / PI + reflected * (f0 * lut.x + lut.y)) * u_IBLParameters.y; + vec3 result = (diffuseWeight * irradiance * baseColor / PI + reflected * (f0 * lut.x + lut.y)) * u_IBLParameters.y; + if (g_Clearcoat.x > 0.0) { + // The coat reflects the environment along the geometric normal and dims the base layer beneath it. + vec3 coatWorldNormal = normalize(mat3(u_EnvironmentInverseView) * g_CoatNormal); + float coatNormalView = max(dot(g_CoatNormal, viewDirection), 0.0); + vec2 coatLutUV = clamp(vec2(coatNormalView, g_Clearcoat.y), 0.5 / lutSize, vec2(1.0) - 0.5 / lutSize); + vec2 coatLut = texture(sampler2D(u_BRDF, u_IBLSampler), coatLutUV).rg; + vec3 coatReflected = samplePrefiltered(reflect(-worldView, coatWorldNormal), g_Clearcoat.y); + result = result * coatTransmission(coatNormalView) + coatReflected * (0.04 * coatLut.x + coatLut.y) * g_Clearcoat.x * u_IBLParameters.y; + } + return result; } mat3 cotangentFrame(vec3 normal, vec3 position, vec2 uv) { @@ -415,16 +435,36 @@ float directionalShadow(vec3 normal, vec3 lightDirection) { return visibility; } -vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 baseColor, float metallic, float roughness, vec3 radiance) { +// The coat's own highlight for one light (rgb, already scaled by radiance and n.l) and the transmission of the +// base layer under it (a). +vec4 clearcoatDirect(vec3 viewDirection, vec3 lightDirection, vec3 radiance) { + vec3 normal = g_CoatNormal; float nl = max(dot(normal, lightDirection), 0.0), nv = max(dot(normal, viewDirection), 0.0); - if (nl <= 0.0 || nv <= 0.0) { return vec3(0.0); } + float transmission = coatTransmission(nv); + if (nl <= 0.0 || nv <= 0.0) { return vec4(0.0, 0.0, 0.0, transmission); } vec3 halfway = normalize(viewDirection + lightDirection); - vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); - vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); - float distribution = distributionGGX(normal, halfway, roughness); - float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); - vec3 specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); - return ((vec3(1.0) - fresnel) * (1.0 - metallic) * baseColor / PI + specular) * max(radiance, vec3(0.0)) * nl; + float fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), vec3(0.04)).x * g_Clearcoat.x; + float distribution = distributionGGX(normal, halfway, g_Clearcoat.y); + float geometry = geometrySmith(normal, viewDirection, lightDirection, g_Clearcoat.y); + float specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); + return vec4(vec3(specular) * max(radiance, vec3(0.0)) * nl, transmission); +} + +vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 baseColor, float metallic, float roughness, vec3 radiance) { + float nl = max(dot(normal, lightDirection), 0.0), nv = max(dot(normal, viewDirection), 0.0); + vec3 base = vec3(0.0); + if (nl > 0.0 && nv > 0.0) { + vec3 halfway = normalize(viewDirection + lightDirection); + vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); + vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); + float distribution = distributionGGX(normal, halfway, roughness); + float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); + vec3 specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); + base = ((vec3(1.0) - fresnel) * (1.0 - metallic) * baseColor / PI + specular) * max(radiance, vec3(0.0)) * nl; + } + if (g_Clearcoat.x <= 0.0) { return base; } + vec4 coat = clearcoatDirect(viewDirection, lightDirection, radiance); + return base * coat.a + coat.rgb; } int localPointFace(vec3 ray) { @@ -560,6 +600,9 @@ void flat3d_fragment() vec3 normal = materialNormal(); if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { normal = -normal; } + g_Clearcoat = vec2(u_Coat.x, clamp(u_Coat.y, 0.04, 1.0)); + g_CoatNormal = normalize(Input.ViewNormal); + if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { g_CoatNormal = -g_CoatNormal; } vec3 viewDirection = normalize(-Input.ViewPosition); vec3 lightDirection = normalize(u_LightDirectionIntensity.xyz); gl_FragDepth = gl_FragCoord.z; @@ -605,11 +648,16 @@ void flat3d_fragment() vec3 diffuseWeight = (vec3(1.0) - fresnel) * (1.0 - metallic); vec3 radiance = max(u_LightRadianceAmbient.rgb, vec3(0.0)) * max(u_LightDirectionIntensity.w, 0.0); // The sun term is multiplied by n.l, so surfaces facing away from it skip the cascade lookups. - float shadowVisibility = normalLight > 0.0 ? directionalShadow(normal, lightDirection) : 1.0; + float shadowVisibility = (normalLight > 0.0 || g_Clearcoat.x > 0.0) ? directionalShadow(normal, lightDirection) : 1.0; vec3 directLighting = (diffuseWeight * baseColor.rgb / PI + specular) * radiance * normalLight * shadowVisibility; + if (g_Clearcoat.x > 0.0) { + vec4 coat = clearcoatDirect(viewDirection, lightDirection, radiance); + directLighting = directLighting * coat.a + coat.rgb * shadowVisibility; + } directLighting += additionalLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); vec3 ambient = baseColor.rgb * (1.0 - metallic) * max(u_LightRadianceAmbient.w, 0.0); if (u_IBLParameters.x > 0.5) { ambient = environmentLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); } + else if (g_Clearcoat.x > 0.0) { ambient *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); } float occlusion = u_SurfaceFlags.y > 0.5 ? texture(sampler2D(u_OcclusionTexture, u_OcclusionSampler), materialUV(u_UVSets.w, 3)).r : 1.0; ambient *= mix(1.0, occlusion, u_SurfaceProperties.y); float emissiveVisibility = 1.0; diff --git a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift index abc0005a9..5a5379c15 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift @@ -37,6 +37,8 @@ public struct PBR3DUniform: Sendable, Equatable { public let transform4: PBR3DTextureTransform /// RGB is the reflectance of a non-metal at normal incidence, from the index of refraction and `KHR_materials_specular`. public let dielectric: Vector4 + /// X is the clearcoat strength and y its roughness. + public let coat: Vector4 public init(material: PBRMaterial?, descriptor _: VertexDescriptor) { func coordinate(_ slot: Int) -> Int { @@ -66,6 +68,12 @@ public struct PBR3DUniform: Sendable, Equatable { transform3 = transform(3) transform4 = transform(4) dielectric = Vector4(Self.dielectricReflectance(material), 0) + coat = Vector4( + finiteRenderValue(material?.clearcoatFactor ?? 0, fallback: 0, range: 0...1), + finiteRenderValue(material?.clearcoatRoughnessFactor ?? 0, fallback: 0, range: 0...1), + 0, + 0 + ) } /// Reflectance at normal incidence of the dielectric layer: ((ior - 1) / (ior + 1))^2 tinted and scaled by the @@ -90,7 +98,7 @@ public struct PBR3DUniform: Sendable, Equatable { bits(emissiveFactor), bits(properties), bits(flags), bits(coordinates), bits(emission), bits(parallax), bits(transform0.matrix), bits(transform0.offset), bits(transform1.matrix), bits(transform1.offset), bits(transform2.matrix), bits(transform2.offset), bits(transform3.matrix), bits(transform3.offset), - bits(transform4.matrix), bits(transform4.offset), bits(dielectric), + bits(transform4.matrix), bits(transform4.offset), bits(dielectric), bits(coat), ] } } diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index fc6d26f15..413b77002 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -70,6 +70,10 @@ public class PBRMaterial: Material, @unchecked Sendable { public var specularFactor: Float = 1 { didSet { uniformRevision &+= 1 } } /// Tint of the dielectric specular reflection (`KHR_materials_specular`). public var specularColorFactor: Vector3 = .one { didSet { uniformRevision &+= 1 } } + /// Strength of a clear lacquer layer over the surface (`KHR_materials_clearcoat`); 0 turns the layer off. + public var clearcoatFactor: Float = 0 { didSet { uniformRevision &+= 1 } } + /// Roughness of the clearcoat layer, independent of the base layer's roughness. + public var clearcoatRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1 } } /// Changes whenever parameters in the shared PBR uniform snapshot are edited. @_spi(Internal) public private(set) var uniformRevision: UInt64 = 0 diff --git a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift index 5e2af1dd7..9b6bae273 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift @@ -65,6 +65,8 @@ extension ModelAsset3D { material.ior = source.ior material.specularFactor = source.specularFactor material.specularColorFactor = source.specularColorFactor + material.clearcoatFactor = source.clearcoatFactor + material.clearcoatRoughnessFactor = source.clearcoatRoughnessFactor switch source.alphaMode { case .opaque: material.alphaMode = .opaque case .mask: material.alphaMode = .mask diff --git a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift index d32016364..e74052930 100644 --- a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift +++ b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift @@ -82,17 +82,46 @@ struct GLTFMaterialExtensionTests { #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { try load(material: ["extensions": extensions]) } } + @Test + func importsClearcoat() throws { + let material = try #require(load(material: ["extensions": [ + "KHR_materials_clearcoat": ["clearcoatFactor": 0.8, "clearcoatRoughnessFactor": 0.1], + ]], used: ["KHR_materials_clearcoat"]).materials.first) + #expect(material.clearcoatFactor == 0.8 && material.clearcoatRoughnessFactor == 0.1) + let plain = try #require(load(material: [:]).materials.first) + #expect(plain.clearcoatFactor == 0 && plain.clearcoatRoughnessFactor == 0) + } + + @Test(arguments: [ + #"{"KHR_materials_clearcoat":{"clearcoatFactor":1.5}}"#, + #"{"KHR_materials_clearcoat":{"clearcoatFactor":-0.1}}"#, + #"{"KHR_materials_clearcoat":{"clearcoatRoughnessFactor":2}}"#, + ]) + func rejectsOutOfRangeClearcoat(json: String) throws { + let extensions = try object(json) + #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { try load(material: ["extensions": extensions]) } + } + + @Test + func clearcoatTexturesAreReportedAsSkipped() throws { + let result = try load( + material: ["extensions": ["KHR_materials_clearcoat": ["clearcoatFactor": 1, "clearcoatNormalTexture": ["index": 0]]]], + used: ["KHR_materials_clearcoat"] + ) + #expect(result.warnings.count == 1 && result.warnings[0].contains("KHR_materials_clearcoat") && result.warnings[0].contains("Material 0")) + } + @Test func unsupportedUsedExtensionsLoadWithAWarning() throws { - let result = try load(material: [:], used: ["KHR_materials_clearcoat", "KHR_texture_transform"]) + let result = try load(material: [:], used: ["KHR_materials_sheen", "KHR_texture_transform"]) #expect(result.warnings.count == 1) - #expect(result.warnings.first?.contains("KHR_materials_clearcoat") == true) + #expect(result.warnings.first?.contains("KHR_materials_sheen") == true) } @Test func unsupportedRequiredExtensionsStillFail() { - #expect(throws: NativeGLTFLoader.GLTFError.unsupportedRequiredExtension("KHR_materials_clearcoat")) { - try load(material: [:], used: ["KHR_materials_clearcoat"], required: ["KHR_materials_clearcoat"]) + #expect(throws: NativeGLTFLoader.GLTFError.unsupportedRequiredExtension("KHR_materials_sheen")) { + try load(material: [:], used: ["KHR_materials_sheen"], required: ["KHR_materials_sheen"]) } } @@ -105,7 +134,7 @@ struct GLTFMaterialExtensionTests { @Test func theSupportedListNamesEveryImplementedExtension() { let supported = NativeGLTFLoader.supportedExtensions - for name in ["KHR_texture_transform", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_emissive_strength", "KHR_mesh_quantization"] { + for name in ["KHR_texture_transform", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_clearcoat", "KHR_materials_emissive_strength", "KHR_mesh_quantization"] { #expect(supported.contains(name)) } } diff --git a/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift b/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift index 45876f1e7..a1f686d10 100644 --- a/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift +++ b/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift @@ -22,7 +22,9 @@ struct GLTFMaterialExtensionMappingTests { textureTransforms: transforms, ior: 1.33, specularFactor: 0.6, - specularColorFactor: Vector3(1, 0.8, 0.6) + specularColorFactor: Vector3(1, 0.8, 0.6), + clearcoatFactor: 0.7, + clearcoatRoughnessFactor: 0.15 ) let result = GLTFImportResult(nodes: [], meshes: [], materials: [source], textures: [], images: [], scenes: [], defaultScene: nil) let material = try #require(ModelAsset3D.makeMaterials(result).first as? PBRMaterial) @@ -31,5 +33,6 @@ struct GLTFMaterialExtensionMappingTests { #expect(material.textureTransforms[1] == .identity) #expect(material.ior == 1.33 && material.specularFactor == 0.6) #expect(material.specularColorFactor == Vector3(1, 0.8, 0.6)) + #expect(material.clearcoatFactor == 0.7 && material.clearcoatRoughnessFactor == 0.15) } } diff --git a/Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-clearcoat.png b/Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-clearcoat.png new file mode 100644 index 0000000000000000000000000000000000000000..b9141d5feb63add77faacb541ea1793799fb3512 GIT binary patch literal 112776 zcmeFZWm{Wew=ImjJH??$iw37Sl+qR`P{CaS#oYr0FF{L-I~6GI?p|C92^!oXxCJ=j z*=N6J|AY6-{;;lCCQ7;}a%TJKbe37!z3p`j7KdHwP|8X8708X7tr4+nKc zY+_*@bwYQ4ud0MrHp;kzdLwDA_Xen;f%Y8r8V?OU@-rIN<3~`(6V!paJs0D@uV4h{ zV*dAQbok>RWso~uXlM#(Z(b^X^g-WmwJ+f})M2h0yCLZ+hqV;%Q$$tgFlvj#vGRnn6fYr)%9gMRA7`o)2qzV|KG8$7+9N{xy{M+a^$ zww^;WA!u+O!wqN*cHQP21K3Kf{xf6Xf8>5B>tC+D^>GBycAQ$>W>9RmuU|XUbPSTe zKdd(9?^OV35m90O-xGIt$4C-vYHq?JmyQP~8vI`;bbuDNSu5FpzkR$a3BdV-CR2%N z(rozu?(S8f4nBYK|Ly}@z5!Ns_wuSLbN)Ah7M^il6W8fwt9x15GQ?k6B)e0HX~98)jHgR9fB<%I=x#7 zlMVh;W*X#(7OvV@f+WU3wg0J3h?gRW(G=4q+1RdwA_fVs%ZVjr#oriX5Gfizi7$#0 zFvD4AAM}CMcrQ@{UyK;){6Ui|?9mH0KD(sY-qpy_Q7;y>i@=(8@CD)Wr(Sm7lAovo zm+gS6VgXh0nz3bclFxAN*VP0MvK7Ij%0o1JnZNPu zs@pWq0>T<)S3F)5LZtc0lz`GhIye_fi5Iwf@*x-q4lTK`DAK&UT-t~u<=Z{IAY$lj z;H7slWbVJ7!$3p}8*UH6`tx7`e@TD!mdZaT>TirnHt@V7G+Nfon{D|sKVch#v|M>t zSA2Cq$>K+ZqOBV|j(j#+qsJ=dWTq=$Ku}+m|2JpF!b$QmCRK2GE~wX#CY*&ix=Uo3 z=1|Dcr|z~0m()Ax!oQ8%`;*&$DsMRf<|nka=c2mYsEUb*qcxptCI2+b4QJkW=h?P+ zB}nVfhd9i2upF9i=0l$Gv3St9wtyfHjt45a)Zrtq@~+b~n))O8>%Z}f)G<3lZ)n7f zOmo9V>6K(v@q$G{Nwv0BZV5bSA~X4sLUW`AcdW=DVp7(7qUg?`5Q3Ae}_IGiL#xrZ?;CUp>$$oRh=Z+iTr!4qbg7EtBzYui77(8v!y4{h3M~;A)>D zMo%cR`n4#@%AAWqU>da84;qoD4vW!*sBm@k9)!5?hG4VZ=MAh;V6)|L{3uCN5+t&$ z(-H~q2&4~(k6GNkDe3vcN)=xF422U^hfy#kK6U)9E>Wg1Cf|*Tg2tIE&(%`YpB?NP z&4Spi3#S4DOK^Wzd@1mpwOgZi0f}6iAzIcu^HtoE8t0*n54Cb)9g##&v%UD45&`A| zrE_9)SEhZsHSgA`s{fS%cB6`H@pPz9aGXJaQmK*uB1yg5gOw1!O??|~gYt9%&~5%a zl=le?q6|rSTG0gof3OKwVR7CIB>Iy(Td;AkXj8cqK8kTjV@2(ipufG5FX%#J0;~>! z2m?gx-g@iP@sCpqECL|n4-w7q#J_4$5OZVOrjk1PO6>ww<@DgLFS~|a=F-V;g4T4(B0QTqgm2DZWh-!6Um|UUK%N&P76^kYN?Qv=Gzu)N zcKz}~)W3Gb&CCV?1a}2EmknSpN0Be0JA%L= zh^>kH#G@;LjZ%&OIO>);3JRW9d}{0M_#%QhaQ4+7J832PPAA_wv~}%cC_x|}Xv-1Z zoH{YYvV=EJd};ZGI-Ge(f;z;+W#Sig8(Y#nmvV0SixhHFKG-7QR-znDHhu+^F-+W3 zQBk6wTuALh^J1XntOBdAZjemL=JRXaW@Cq5)*PmFTy?qN^I0vtd-sy|hxzso*LEKs zv26$2Bh>BDf(x-k>#3&a2)Io+b96aZ0nb+lY<(&} zyVfW|(+2#!cWDrFt}qZ2x81Rwtm3F;0~^?os2-G9Ds`q*<$rc;&4;t7v^Zc<#;;Yy zd@}yGpUkP&f>I?Hl=>8ib5Lxv>UCV6ONW$QzF;bCrua{0Or!zW; zr2=qNq>}$h<|~ip`}B9i$pb7e<_FSb($mCw*Pcv=(bs2m93H(AU*V=_xao~lWwyXc zsE>r%>5z#B`a#=LZyYW+o$u>rO!5B{(O9U|f2ZCLnMvyH z*Qq;92pB1_iNmW=9t|sx(uP`7?l#mtoq#CtlJ6)p=+Xqb0|pGcZ~W-%5G$9&fp{!W zufMfx^_p?ihoMLZYKag%rf9Xdy=e4T5FTXqs|~k8gk|UNqg~};Eu`FNy2z)Q}5qZhn4!KJ`_HoL07i7 z9Kl{Rb9y_BF5W>XKSdp{v2I=rQ|NaCNCu#b?U(1`S+NahQU%#jhh)LOMuRD>ADN9T zqISNwLt^biOSv+7wN-4!PS*8lDB z!UZp{7+YxjjI%@M!3by5#g5o$mB49&uo-ivdaM3bR#;F7Umhl)ZGzc=#b!P+YEK~J zJ(Xv5cd&n+jhq|<9=2yzbXOzY`Xxb~4K~~S*c8kG?yrq^qV_w;RF+rrgNYx&g#yJD zd?+Fty{hQ8iICSiR|k_RqL-#?W-GISA;fMO=gOWv7%ul81tGtDM$3nEh$_dU*%RyP zeyx(aJSW+ixT7%bV4v{HPK?a}F2;|W*u)H* zE4N?;AadHyCJ%i-aw2b%Z6;1;r4Bo@<)`!L<-f@tOSfoWxq1mYUAoaE^u_{_P$jlW zp&cCsd=`)5#I78GQ6m6$BKggQ{o}^sF5eWNj+bCZazj_`XDN&j_bXmi_=KS_*MxiH z>9n&Nx_OlAPfO#!07Ex{i!N2V-wzdGS*0U*bJgH)*OVT1Y}I%l0eHbI%q!JkKCHE3 zt9urzP(jdab81yGDj*yB9#=D#)ak$gfZ%RHOqII_$JuC=5G^m`^0jpfF0aOJA@$%z zXfK~p3J=lD$hbC3c+^&iF`@wk7W>~;AkOFTFL*}yyyL2Ul$+Wo4NRmO7R^@hy+9{l z-oc?NEULI)N4`oVk^b8Gu>5|}c4U7C-QKm}^LYL^g8~n@SjDwphgUqE_y>u8yXx?a z^%B?SDVPPJgwWMSyJy$AM=s1SeF}#UyYKXNcs}OYJ-4U!^3mX)j@c?isFVCqUzhW5 z)7=Krn0oJnYq`KL?tXQe!p^4#l{WSMQbV{*;>m(`Y%e=s#dvB#e@;EmG0{tN>ivGL z$Jq5<;zTPljeR7bY>iof@&(!o;r=;z3;CEo8aB!5GdSF4hZJrAyH7q%k;kN2srZy z4dG8vd>=0MBDI$HF}Pn)1&!t9!Xu@xHb#&hM?!nJ>MWbVN0NIs)!wenv(d7=_Xgld zD9?-^z!sZY9hjuquji-|bFJ{3^}$J`JuZVOaZtj-AR}7d`$Gtzk5HPFpxu@_ms~0R z=7h{W@vEEEi*@kix!7l-UFKGZ_KSVMO$M+xhZ>ZLBf2=J0>HEMe@C{GNxbg3a#?_e z^i(H3QTrLhtO9S>mzS!4UU)f=LNTOV^*^fxrU82TMD zbc^3ULtx{atcGD7D#~SG^xd~c|K*t?|1HT2BFkTcx?V%H$x^gQf&M@2pvJ2yM6yVzm?x1q~bLGcQCnK6STj;7dHDu_4(o9l=r;J zj@w_0PIGR_)+X;tJnVcTajMSDw$5suOE1bj_p7vO%C*w1I*Yu3k8tzZ=c#6m~dY?P1%6bgRbVqlJgapVT zLQ{#6#4;>b#kN8IcYj`w>pKEmem;tIZ<)%TH~=cb2(=92%8E^ zF!#mUSc5B$i}Nw=ZC;xE46V`p<)t>RnGdfa1r9F%8J@he?7N7HvblRDKZ?E(C3}o2tC~CE`F9(?){i>FMwjjTQP%BPVMym*8*n%bj5=i|imWF=s~H^J z$1!~iv@4y_W%Liki)sKyvTgGQ=kC$J-wp^o6VE&}2W`%bES}ao9mw5`mM&%-E-c+4 zQ$UqRDq@dlo0_hWzN6UXn_Ud|7@G6toJinBk;&-A=U(i_G32pQR`zkIW&pffV0&o3 zt)Z?S0vdRo*A(jaO@L$b3T^`+asWhaeYG61OLaJ5wKKR9aBwpN8XD{=Z6}%@GpW0x z(E^Cw@-CD5I~%emQIFtW(Jz@43Bd<&6RIH7IWJ)gs^$a?s>{Zf$(pC2GLMom47V2v zJ_~$xF!`Zw{m!dzF2gu(uM*^WFWH81+F0QJLJU`LmXW2cYbZQp0smr^3q;Eq=zbsi z9Onh*@=4WQ3`6^Dyy=b`EmxU$?vdbqK3o|hNewQ1FFF+JGeg`%V>-23r!&x**w+2R zTxck-7thQIBJxj-lB;V~!x>Y^ec9>Sn>6M9PLdm^D>(qoO`h)%s9P#8*6DD$pZ z>b;?|!E#<+06k{8UY5JBORkUn6Y)}5OC(rx-MmPV8nby(q76BD_>8P`d9M{RBq+Z? zE)s+ki_g0XmCe4j)V8UTZH~0BrVVO|y2WOa(?M2p&Uyaz+Np=gHGN%l89?K4-_aWY z;%AbusZyP8=p~IzXeb8`k<$$QPW`5ezd~m+p?a2dI{;t{Qw? zWFF`_tO{DIfBDA_g4zgti{RPd|I|c$OSoW*LoW-YO?)j9U@%e7ckh;HbE1c=Br|iR z44kZF1Bwq!mbSpUhV87xa{8DO*Aq{$pMVsP;N?Nr^Znpt9Tc0=#KJ^A8~-n(&Eb2dN-L3s zrO|TEM{tT^{f^XQNOBHh#PqVOebXl|j$StEApc!VU5Dr`F26?|$kh?c8c41nse{R# z<#m~~c&8kfEY$$z;fve}`KMaPuxm&f$2cN>&fs%lIDlb6MP50h85g$rkNjMQLz0tI zUM&*+^WVM(3!f3^2^Aj)dK_e5tZScO5u?N9#z)qV>^3$!SaLs{DzZs`Xp{>&IQ_*n zWxtE7-@C<>{{_U9PT{1+EC*l)4qCZS^EDO2e5_x-A8NAAlu^Ad*@ z2mJS`EuIx^ro#nmv&e5SiNxSanq_h|rvAX5=3kPM6Y-Yx$h;oD-22?%tR@qCTfj<6 zHgQXl4W<;s)$~Y4^K9o8@417SoE|4FX8AKrG0dy1qLl@aT_-kMm{4QVwvU~y^T?uiqA`)zmmzTBGO zo8|t~%WY{gmxV=10!`ECoOB;iReSj4a~zkbSSG68+A$?kX+?5`&N-MQRVSH9xL6E@ z_bWX#u=IpayjujKpVulUz)mQ;>MKq@ba`u`FZKG7^5zgCN4%Yn?&Afo+5bz&)-mRCfi{+71+X^QqIZ{MjAk@eHBRJyUWmCtbb!1359m-wzWi4v9-#Iq3+>Wby2?owKQyFHUn2pc*W6{!$-`g!h z$quF)z02<7tGY7zSJg8FU8t}%Y`@cQ_t>MI)#t^z6BqO_iEyl4jq=(}jZ(Cyo3?i4 zNyzZs0hpV&wTK#nJk+|SJ)S+IWxu6h`d6NroUQisyv9z{MO+LtmfKw9dpSxm-77Fr z*?(D)u>Dz;go259+flMA%W;z4vOE>~_aFQlh0cxnhW&6hevQ7u-%RY7BO-@ohA0e# z{xDV`1rS62@;+-Z=oH&|q~KX23L=-^!=O?U81VqRC8FF4CXo;#}-A`;{MV9ryP^yuvHM( zSQL<&*%LWNQ#F{w>@>QYzDzgJvEMn~!_xE->G)!&ealOCqUdkA9N11tiUbjWTsXDNt`l^~efmXwW zul1+QUd2j~X;DMj%mD^yO@RK%`anu2&6?8GO3gRb-wwK?*k-cWjE;x$R@Jf01)JH$ zW>!)*&FS{k`(Lyx$_0W)gy(+7k|8M+07aHgB5qtx^Zo2PQzh%f?F-K(&^$w9V@c;D zD*bnn96d8v=VYTeeKEP ?=zQ5%2(#0VN|m2Q@gNk01ipIm?Z2-nxP8vG<(acFv3 zuA)}Qfd-pT<%o! z-)zI;5@xLeb4H#ttb6OQvNFu_F^!btNx|Nwj3c3`d)s2*TBQz zZ|(z%krRCI+mb}qv)bh2&!0AvyZCgF`Ubpc*Zo5u@swuEVY#g9k;-~lfyk`+=CO_g zc+jm0HF&jFytl&=h$3$N>MlR!9dX=_FZs&iFBMgXuT+Zlotr^A1VW(mu5H!BUZocB}bf>=w!b{L*?e(7Si`Q zOhp_m>_K>jB0@tnZLn@%C7EmJ*|3c5=g)KRg~}sT6EGdPGK!pD*FAkj(fbGO!`Dyo zn|t&bTXG)9iVQ3y=HyossrX1yD)7U~2X8Oa z+etN9^Z%f-*Nc%&f2$4rSgnoVtiTF?a}cAJ)5+G=M{DRw;2-vqL1BC1;-lJ+c^=34kt)afaq3$% zPR=jgbOJG&8%ON%n1nKZk>ENj&3vgrIVlw%r_baT+d{o>+mE7I-9pE^`-VP-?gpK?mVyq^FHIBgcs z?+?-U#%_+;XCZQ+zqDR7ATRBM@bsM?${8dysjRq^B)46l#)0RS!dJ=E@plW1d+V>{ z2B;2pNyjACA*Oe6&|{S`-N%Cn#~ zc!p{7_=-cjhT|CaQV%4@{@!H(L4wdb+v5jF7k z15pCF+~-(jVNP~l7WxfSQ}Hp`mo;%dm2uNN>$i)i`Uom=eHOqQXRttji94fC4#bUXB!*VL9$zB z4Brl`p3nvMN(tLrO&HhBywq{o33 z(m^Xkc?CozZ2=(0gWH|m>k3np67cNNY$_175z)RE(s{(bYHpJ{fo9dHO7_gQJMu-!K7(6* zD%CvAp3+qJO5w%(PStZEX=8`X?2T=>Y+Q-X=1)b9RM{rE#_fMX=5AcS+PDf_)MicJ zWXr)A>4}H?A6ELk90xDNe2?EMQ(Qd*4Gk*A7prqk5jTN^uuA9Gt~*l6TOtKx0-5VW zuZ!8*FP9b#Bu&PD(Q65RSe7M?^kNIaS6uZ1B&>9q-{jXW5gTr_tJnKQ=nc} zTkITn=AZJ>R$I(NR6>PiM0A%}9T*}$|HcEJiT3uqaWo6;jMW~$5S$pw$L^@P>lNd< zc*6Cgmq0OEe6Qc^o_qB2i!9~qjC-Gx3z9ajO_*W57fU2NLgd&OERS;U34N3d_x}pd z)$_#*+4;pT(z#`9rlyTz!j$C`Pkrp=r>E=^YcA+bMuQ%3dtrdUFumFZsYCIB_M&AU zu+`t|bB-o7|3EwCTOEbd3U0F+_>Bx>-D(TQPvx+0hZP9SijFRju$H41C6U^n*yXHN zfP?kWuDxY#S~ImjOBlr~+QhzLV#l4G;mn$ha_XQ7QVs|4@o2>ntku}=WwK4peh^{#9lC@BBwgRGYhxZI|h$bb{9i zMS`&-jJmj#KRj#r+Cv9+C@j-63pcD!vr?gBTo*OuANJRK@`b0q-;i@Fx6#IZIYo}E z?dH?jGg>i*XzCDcEQ2BTgk{FC49CAsm6oxFhi?LZtE~xM<<#6=qOu%WFTN2 z51E%fmUla_28pd7 z1s>CCg!0s)O5??&RxjuESBK~+PTP}ukn@VKyh*v!>WuU7eQ$|r4d0modLblrCtBH~ znvJv)#M?~jKhXHSgpiNZuu1->E6m0|dq^lY50+H1nqCsG++r3_AT8{*Qim1?t)xnN zDV>hTBD5<)CWrqU`jHlg=)$K~RNkVkFh9h+xO`wEkgv_4O-yPlY5#D-)am>4n~UK@ z=0?lc;tZdaW9Sw940T(mV=gJE%8)w^%~xi4?OdDsB{^ZFQOHoiB6guE6E{FbJZjA^ zD?ij(bkLg}(}vFzQw6nnZ5XzjXBggCtIx_fOn3c)WX#2&Bf{I>U68Ao%rS&>LxCJ5L6Tjq|5hr^AU*}t|Mp~ zmNaeGbP&cL!!Nb*s_>9{0iT97b;zNO0!4JtZnR~!|4;{JoU7p+fmfM#%AYzUG@8!| zeY214u}zIi;FuHcdMz6at`=@5TxSRkVDw5;0&Zx@9>a#In4_W2BJQ}rLWZT@n^p{B z`mvvx+%Vq&UEIp=tSfImM(cBHi*U(;N345p5*FCfN(EO%@CeyfDEywhC5hccsPVsS z{Y6Pt2?=xASIP5aW9c=(aH1d6jX!yBZB$e9Q^u0qwm#&#GhwSDP;|zRD2^&Pn}j!5 zC-zkX8uZ*wyDTHR&J?;5sl>dxcp*AWis(Z^XH3rw9Z`j7mOoHV@0e5V*RfK0X@#`eizQJH;P%e+{%nqQvAl}WDeIjj_mVI~+te!}f3 zFfE*YwV~;zeFhbwp(EAiWZjMy2l3QFM6=DIrAX+Bto0cB+xq(3(B8dSv1umOf=6S6rDpKD(?PbaXTW4cEx zvQV+?pqHc>+rH?|BYMGRLCGUw5f{lL_AF_DnGYvoV2_`$oZh}|LX&{D2dQBe@cuJ< zaEp3rxG;8Cowhw_>R+@%AX!+TLn&SjX*hybIa?9^3P*s>aumK0P$x4v)6d(UcES?}O!9wKXSy%PeYJJw^f!_VTUHJUA{1?EYY$gst~Rn~l~ zM-{aS(32&lW&pUF(G-azcO)`s{4mnDm8N7Taz!q&w5T^5Jo?;RIfcAGi}!u@)D^Ln zZs3x6Z@_STR`|&fp|t0JeCjQ zn7h$>{U4Ps3+#te;jaB2Fiuyo_x@o#awp(IPe2B|LQo4kvb`^XdHzEPzEF$T@uhFA`B$M%^DpojSdy$4U|XjXRco7xf*5^)}p zO!vWLjIXJfxT*L|bjMH$WnITZVNq1my3eBIbQsyxE(PH--4{++mD{)u8py|beN8STyvg0A+#gXYwdOaaPhpl z`7Yc1oICQq3mV|vP0GtPWwvqC$Yqki%alMs(tk|+g38yF^qe%9UVF1ZfQ#&k7%pTd zRZA;Tf)0xD75RPfk{*7L;C^9?+=sE<0@LL5w;-H?@0l#u{HQc!X(D8 z*;rk|mKVPlF{DAvus`l}_dfoW#l(4`e;}ijqcdQ#0JnLwA*Axfpqfs`0@h_%c3!4( z1AS7Iq%Dr1o2Uh$san(wmj8r+ZDICUN>fj4!H|KSFxodPks9{iUaFY zjZY&RQ&}RI_7Hde(1+ZsGWApwsqMT@vUGKsXB5UZQyzFE_9Zd;+gOZ0^zF>B7SsXj zL|ppi!`mDI1-0mU!q-B13iG0T;OKv^K!*1&67m_P>rk_ezd>x>;UeI4>>g9g7N+4G z$XcXQw(>0`=uB-A?JXb9y9{)b-*(S%-esf9U81_i|K18N?fdn@uuO_|Aaeb_pYur# zdz)KSN{~s3O)A_j%A(5?l3NU=)pg0{7y7&LWeJ9xayX4z4|D%M2XTJ_m77BpHQv4X z2TflJ%Qi>nRLI{Wju^N4u>#?x`BEl9hq0mP9Njx6^}}DQ=Xnnmjm||raIf)0MtAe_ zdcsErIW!!*SSAanehiDuo2|O}kI2C+5mGitDe7S>Y7X|RO1 z{H67|4C^*3D#z;$sEB(z21PnDR3u!fx5C5JhVQjar~+Mthn?_6Ua91)(oN8AKz=Ye zy%!IcsUPuW3)|{)u|}tPPdm@sZ0%DCO)ERKf+3jTiuB$>8R3|Hu|$DRXWHMG_az?Ycwibsym(XmzWhBm36S| z2JDiIzMZ5_qD}v=409gRbkA5UHUz_`ANwa>i_e*(!AU^4y$LeFh<)GBvQ$82wTD2q zP5b6FsK7Z+`kju=mRaVVO3XTkMf|z+w?874hrR=C%;!IH{q@{F%(uflKjfgMYl+@? z<*sN5SyVOEUlnl7Z01;}r*{VYXpUFs`&no+yec}ywcsc&>`0w{hBG;CL1%M~|2f87 zHwXU|z%#v)-S1u(;d{Mom{Llbho?d)14O^&4!u@myRs5O2|njT%W*L%5cPW``2O_^ zM*_Pe%`~vjp&;(p+-RKxTz59qSU(i9=%ogZ2Mod#esC6-zi@Hc+!yVY&O(Wk`eS z55lh+8*Y7Q5c*Uh?H|W5pVZgNKF)N=z?4m;nU^n9f;MAQ=*x_tC&XVXJHpK=>uf*A zheKzotO;f2O0PQN&j}y6YmEh)9BOVI3~$}b=n{Mc9vY`WxNP$ze_ilcbO3Fs+f7C_ z_`C$!hYaCgTZO)4I1edyE`G9m>x|}5G;pdn)cY}J(V&$#om%4BwIG4B7m;7h5RsHSP9e^Qb{Sk7aiUz{Nx^*@UNm8@-3)${z z)Ddi6TJWlT2|T5wt+s_+u3Jvn^Bi#^&eIC*O`-J8+kHO?PxUu>?_NK7sv_8n^S;oG zJUM^ux@{oU^ZHe$H-R){p&HTc4wgXjnq7So+D{XYlg-9o_ZiN)JHc1jCcDl{sLntx zlHI=_K(+Xvm^ns+ryd*Pnx_ppNRyz0vjx*^%vDYfn!o)eD>;THshd!T-L+*j?#crrq)G!YmMRm^k)RX% zww|4JuP=F;bncVhV|LXED{Hq?C)}BgwK5+o!USOFbh=P%j(CrGp4Qu5zC!cF$>pHk zB{^zTp34Y&wk~^AxP=}Ioy&fL(kQd%$jk3~FYiEdf(!>A+EW=44pUP4$j(c@CnJ_G zJ{P5vwGqEoT2~u4Ic~Z4Pm%@qSwgz+0uA{bSGJz!{{)X)ZPI-!vuPOAP|*9z5y|S1 z{%mk12UInjy)%VWC!y_T;^2{=;%qJ)$1*uN#E$^Z;Qqwg1_{$^Ed}<+wQeX<`gyyl?&JI*=n~qgZm`7Kpo`eYKJDI@Gka5Q?mSVceb@ zS&Zw=0X`^1l+k7aT+)zN)*%+%BCjg0n-@R_!j$(bzX{W}3Y<;4BrIoD;*=bma`x}` zaK-vYIF>eK3&wEW)pUH0EPZ;5LcpOG)tYB%6`Mn|M(`O62;-dJo>wcGvCc5TQh=>8 zP;^ec0XA$iRCcFDvYB@K)-R_0eC1Xx3(_=biJzdnC+G1kI##QWtMC)L-Oi7K&Anwx zynbmDthP(0Pgq}94Bmx;o3I0(^fz8m5N-Rxb=?QQd@qR{p3!M8^pJHKqemacl`1ZR zXe@NEH&JJtGIF>}%(^+QB#O+}uOmQW^D@cX-o%6q^A2Nf7lS8?Ibup5Le6~%tE96( zglLt%KUPP7AI>LcpycFy5ZL~Y{S8}Uq_GD~+F@~AtfF&-2sA|XRDtlJ0z=5mT)9~D z)Gx>N+e;Im(BNSZhZCs?*W%nkeQ2wi3uW7yqgh<%^3ZEV0)4!>ORU2ZJgX%X%cV4b zbdhz1oExW2!*;J|7MllP;C6NGq=W#ay_|bliK&5C8 zG?VL*stfV2qUo9>HMpciOYmt@?yS4Gj8Kz6XKd6Abv*_d8x__uRpmqkzKI+# zy=R?H)xP*+m52#-!@i3RMr1(dllUaX=ANaTzx4wM(@b z_|iqdp1e@0lFhlt241avPi?5_-74g|iB+nM(#l4ItFA602wIq9ugGd?Ip}8QP>v2mL7TnjGax`mFww467-z>${ueqAd zSNg?ofh|KZ+L^S`tvQF5bZ%lf2d=1DHhCjVOLA!6LFOB8mI&K%2MuleqK`@j&!O|D z!3`8Az8%tfM;3QQ_3g25P#~^M4AYbF%7A5QPDBdo9iewaZ9gG9O$S&YDTu;Q%WOg%Gn_hs z8lpatZdcZU$IaraEs>X|q(`NZ+;T1QF0WB@uqQ)BTYtmfUT^z}UNY|Qk`g8qxtO~< z_1J?79X^-a+Vg5YkO{)jGo>4uIK2Si9+)zYn7dwt7U{>IK=O~x;fs!Fnb^qnX3x*V zs)g%&g9;;PCSz*`{u@LA|D=Eq!k=3dTt?LTX;Qj3XFyMW$k;bF^;0MN+_l0HEV>I5 zzLBBAJVUZyVF7-D+fsZ(=48plZ=8BFylLAeHnp*m=V=7VEET2a!^tndlbqqpj`Zn+ z@hrR5og^(k^5>oVz_`@wGA$=)*Bpsrj;<|@mh4I^>h{$L9hIi3sdu4& zoRePNZ#+}lj`{A*B| zX6-j0&I#+v>60aNFXfNeo2&uKN%Ub@7ni$pXJA}xh|kNBs(C@X<0g-9D-L|sO{WCJ z#rlEQtc`M+|5E?uVNfyiN00?-7m*Ff9jHjItVqBL5ZV+GR??S}ZZ=ZyDIqOi7Z*2X zgmArIB#)3)bPHK$z8>pQaE;E&^JpPo-zHL7Tuda^Ryn zGZo_(SqcM>VVuk=9cC^rfNt)Z9ba=&Va_Ik)6rYvRBJEZ5C!}kyI+wRa|aoQGFuqs zirF>OCNo9b+&@}gon?bMR-6%& z=KTcj9U{x`Y|tJOFEjCqv5RcHFXvL(7cZ=QeO(h^DeNn-Y6Nvr|0*T^2Nc5?7cFlV zK$9R(+kb?MToJs%c!dJkMvpV6EyS7zp-FeJ9k<^_F#C9tzc7%Qyy2}CK zr;zOg_^-Jjh=UmSje$q*eZEB|dk)NP#K%)M` zy)WZ-M}JSm8oR{Y!1P_wq{M#TyRe~Mad9{GLx9r~fs-jA=CylHWBXd;k7Nt@3t_GL z;PN%?QqUx>{Oi!sYrAv2hO-WLHL2t%{4B>lo+HDq%Y@}N#`1{RaF|NJPZ|_->y_4` zyszS2LM*_Uu()?vnPAu-p*;s?w{F`(myN&k=TUQq_PuELeMLX%8cc`+ z!k4R7@E3LD2Q{1D%+E|(^5fnKi}&-pd;U;!pHBL-SLHVikGqWVu*yQ@+Tkic^HZ$|Ge^Ice$tzg)tpK?+AYDz|^AEcK3Pnh5UsBE4470={&((g~Aqriuyc^;8yjQ2IS?Wh~INbPSj+nQ(T* zmzfh|@oclBQ>6J|=z9T6-AK68W4U3fmic>6vhnwV@6y$&Qv77n5>OnLpfZgS#Vk$~ z(!E*whqh0Vn+m8H7p~}Kv9j*gXDw{xw9wE_t=THLGeO!M*OFM!Jo&a%@ckeeO+vwV zYT7BR;ex#&qxreUK#U4=(`ESIXKAF46g0g<+mlR9)53?eBv??6 zD@$khuGfJ2>g9B1fPI}Z6u5uC;`IB|LL1y+=)~(S(=c0kf06JT*J{#(p7j(9iX}qn zra}FCF5ztZzib1pUc}7-J1^y(HKvoz47)g&-1&j_Gx6w|z@e#tuBiGNjd4A=eNZ3y zsH)@;W6{X<(>uYOziZ4Ac zb{jL|7f0$lVna;$0I9h>DTU2cEF7`#JT+iOJO?EuS}2_n+GrMB%RpE!PhMM;33Z3_C$I2;wVIml2F7t+lPpk3n`W8COed z7Mz3J=t}Y{!7%#0pAs2Ndyp=>MIbfzx`LYa0#avR?WhecIrp6iWRGj)a89;1MI-lY zya4=U!a0ZD;!RFlh-}jl6ch3nec$%0=B`H%WihUOPtpoz9_Xvw=kSfjd>=@Y2z>)1{l@pZ+T^*2IF$%o!0r4htGE_RZnnTL(60 z4GcyaqwV*!jf^?`ZmSdS-e=ISM>JrCn>wN9OJG|{ZcksjP#FG+OPfC5tkTsxb)KE0 zY25r5qaoR6KeDA);wg%6f1EVLOs(pF9$X$$+;0{&X0Baf9oezoKSE~K)D4P=Q6W`W zVf$d3G1!YZR3+v2@A$fS_>zZ(IIyhg26mp`la!x#3Z- zFjG#!Weat6_~k(5*yeH=@ogqtZ2XigKJk?79GQx_S2;B?y31;VjbHTV$(N-Cfd$8! z({>@+F#07otiI=cmL;CXZ)^#gd-uMRPP}lr6=Hk>#+@+`QWUnGFvVHwn`XhZ)wCbY z(~7XqOD@Hd1{SefoEiEvkN2%hHM9@>dE-?EyGpCb3!6wWe+d3~pd6j2eo3_J}W!rh%lERa? z>JM_4?SB?!iGDI8r{?@$LV%D&=T*vyg*kg{j`wY1c#f4qP273b_%nc1U zlZ_{Wh`B!@`I=4Jz~lo(y60&a3L45@{N*CA$*|e8e3Ktz{Q12$GuSZuq;LPUug9t% zpY2n>kTsT=0fb-ZYD4cW$JNFrpWvbU^nJ~~@EaD`jBH2g3sGyfFfVa#Or)-^(PB@1 zCeXednzw>~>-{@53n;c~bJZEB8NjQ41Z(`eJ6^2lJPtnWRBptC9Bt4w>w!P6mW4y~ znPm;#4H?fe$299(<2=@HEB;l%_Y886X?19a{14ezlLn~c5=Sk-l{Q?$KlByX1=M^Y zP?p}n04z*)Y+5CgvPH#$b}XRWUv{R;`Yh8N`OTNA2IZ~k#JJGcByB~uGakq$r z#I+`zvxCI&0sdGbEki8q-D_?Cv-PlD-jC+sBh{2Bt68RB z(_LU=m513NTVdvDWe*=Hv@40p7Mo>D7kDf37^hi1_@d1?XaDRI!7+OmI(>;bu5ntd zJp%|o_GwJmWi82Wd1C)yMVKuV5Z?jmQsvVeEhb?h zMmPCcKz$_)#`&2~->X?bXS$lJ%RtQlUfp9?BL@Swz3kyhJJujZCvwLFV;gwJLf&T! zpYygXU>-%@MIHcpwCX_qMcjcMkiQ3Q$Bvd!JJ3NgzuIxp^+o$#KahKlE~^MFQ`>MF zVGFZ4`*sGY$C_LM3U}Alg=B1^nqkXA3vDj=y38)z7UQDM?id>jQy3bEw_sK;N^d!{ zr##dlYDJ`G0S!A#Gk;2gx^Q(iuyxNQu{6+f6Bd+x0<`|j!R*{`{#GzUh;SF%vz-8_ z^OMXsu$gB8KM%KpYbge*)VRvy2xzU2+QtH zYre$ta%h}eIda^ZPwr!o;mqrMj?ebUb!?ksXIVKS(QF*_rPC4_0+M344`YAY#UCCy zhsoi^@GPfvA+DKcY)x0ik<-)39Ya)`iZt34cew3^-nOs#uv_!N)?~FDjSvdUMK2Dp zm0J~i9xPVLW?wi7NO&j&S8{VWVGIj!A&b#c`UrgbVgoHufnDo46g! zBJeKOzx)MnwV>J-16PoNngP6m2d#!|4DgGed1AK3F3VjH{PDIIEe05u$2#jV>V`KV zEYWTn!eMAJfWPayn%1nGQ%kPEB;t6}n~* z12@Q$EcR&vn%V@58(txvkS-)iM0p#!Z02X?(Y`32rXp*$o9_LoVFwbdrP!oUfW(2+ z%pb=9W=OoR!%QFQM&W)NShIogYmO~B0-J1Np189iSijtFcB7>UR>1W?AE?JHpoZr$ z>?Ha!nd4R(B&KZjtIY!PcY;6fx%y&gXSDhpl<7bm$PhS0h%z?L2E))>5otE$d#Rc^%=hxCpI! z?EN@Cm=i`(#T?sMAq3{x)r6gHvO6%ya}LWTe=q*{<<6YB>oa-qfvwF7a#jyH`&Yrx z-|XhIjP0|1v{|3(!~4<)LSp8y^&fuo7`21W!RTYW1lIj9I@|H!!c~Lm@4TQfMMA>q z(6Ml5IxfslnX`aT-@3W`hPS+IhT0YbSB!z00lZ=dtcGh0+{07i=`_76XFH>2y>n@o z=xuxV+>)4KURU$cY1Ocm+43G6vM>E?Yq|?f_kD>zGrm2_KF&k_VUPBhocnyXY{%q} z*uB0%L^vPYM#aDb?m1dN&BkPN2eTeN%;T$KLiQ997CvZ)S-e(^SzId|S@3PdG+aYq zT#VK`^M+H`N5T-=_dAxvxvMU{FdjQ?%kAB3|t`w_*Sc> z7`Rdl@T*^VLhg%Q3P!A3FD|u9%zBCg<{Xq=vwW=2R?Jp>$1U=h_#SguIwoKE%&oEq zxY5-<^O|i9%c&39w|sM4mb?EYUUhpzCnHs?4Ru}PU{wN3E7qi96V(b@a~Uz4`5Un| z^T)4kVZj(hBa6f+6o=;kEuMXAl}~^6Pd^`*#?i(Uc^MaSw9TKpFpf_I01_8$xwvk! z`6KJF|FR7m+9Q@iERMH$bCZ>0Q@ftPgfV00wLfO7jSl;T)=Wqg?y?+Luz#LV563j_4=nnX#-lxd|3IsQ@{D z!b{>smSwh{WuL8kZApis581c!W_)jsEkcBMqjFH4aP=b`n<#*C=i#4i!k9~e{l(jK z%cX*<6x-d9Rc)>o12qG9wT{=xkCuOF-vSG98fP0a$q6}HVvlPXE)sh>Io5Bw?2H#7 zR7FB#;@TK=dH(xlP1GPz+J?YLmlis#;57r=B4QnnBEo>)Exk6w7VR`05iU;H+CO? z`&P{YI(yYz9R_L!@ai0^8YvjClW@M|nyi!b1sS?btaZsTtY?`mv#$6ACh>xM<6(Oe zji&R@;*IgvpD=9l1dWwO>%7Fyt>p?^w8!{_Z)-j1JhA8TZT(~cySklYKMy@k_iMQb zb$F8981|@67@~@X1(y^878^cjd!a|0t?ia(=gB8fqy9t`GxN0A8Vk^&}4U4gcW#ceiif#2@)w?SjISb*y%x?jzT; z;Mn}!Z~nJBcn;|nPP{kMdHR^p?ep?E6RmW1FasS{1~9|39$jtWVYhbaMTE=T_0rS) zVw>$-a`^6RBlb{xOpZ05$lGiH+BLm_qp*k>&4ZSGvphHr%FJ}DLk2G?gX^HUZ^Du| z#=^h~go_&2XPG`)*|@Bjj&mBT*<$7{d|T`B>>f6<{9 zoUug`&IIz)z@KyX;+NC+{=J{D+v3`D?vw484hQ>2$2`AMSJ8>{?JX3Yul$bR-FK^` zV&I$%T>KS#&W_6c>N4>3-~LOxyEwVs!bvVxEA~+I;oI<4?BUF$7ALB>nDE?dGWVT$ zaBySy+kWw{nu22RA=d{FcmMd!e<&PQTm9}YEXteOaBk@XcuZ}(Zg~D#CroUfTO(#; zT$31OOScAczIH2K_+*^am5#POwk=bdqa6{q=9{o2PMwVEA-_ZRF`H{Rcouh_R~md< zTx2yYG`5TJsM26RY1JFZbv9hoV&kxch3tTu^!|)KQXB7GzFC*DcgQ^GP{X0fMHt$c zCL<746m~A20X*Zge_WynM0*3z{wYi`urU<>gfU0p{OeI;Wdh1`UkAB^^WUfVZQyr)@Ap{1#`fp_=8xfg_Yn-0 z-()ZrdRE|BAMqg*9tYyl5|<3_ZvN!oaG3>S|3m!Nt=qfz|CZ0(7(v0sfH6=7V7(F+ z4BY%*|I6J|Pu#^&;hf!wB>pZ5z872=V^CV4d z!sMIOb(bShxab1sxz;0WALo7B@=e1!N{l?wt_LlxOC%zbC5d5;c8@7sR@)Xg5aJ5o z3x}5G(gZu^No^@I7P4AwLZ>jxF%Gta%*=)f4!gJZUWT2Ohx;kF=a9V(E(vrGV%Ta4 z5Sei9Xhk4lF3hQd5G}D>^wFk#*g&RvoCPFqA?l9)bYhT&ctOX!#M2TLT|lZhmBkSG zdk}L04HtET^HeX)&UPP-0&s2lhd=zIxNBj>9?xGa7`eNQ)e4!Pf1!Ma#2VHh8^&`k zZS-OP{Lsu9f!Db0>>>k|w$nNex`f;7J`A7ThJ9l>P`Q(3PZFq&9FauQpp76oX zyME_y{#Uy@cW!CD{le2K_;})oqK_?4`kMK3S%n@jo`ARsT_7B&*t3dXBmm@_6P$bkt4=w=Ba>qQHt=}MV4?91&!mE7%S zTQk!uy4U%RMIbQV27VpRm%kM&mmmAVzlx&o0nTF&Tfuh=!7BdL-oO`0bDwR9Ebf#@ z7|ye3ts}(;Rj)QNAZ06gIrzz2WmJ9f`LbI%IDr|#}<It8dml-v*rDLrtb{dv8J6(v_3;I(I%6 z*HsG=_q)FgWJTN2cZMR4-2`PUD?<;NM&)x~dhmwwL*%jA5XC73>f}!6T2|gSAMRfFGe2Ro zgP0lol{bGnve$Bo3{(Mlf=B)R{}T#7d~ekY&p(CY@3t3yw_5SnXZ?7R^5Rb?Eb4vb ziEGq({^H4oIEn48U_mmt-$cFz*_qD5;m6b7ZubYi>^pb&&{{MR!@Eu_+eg`~v9;LH z&eP_|EVmp4r$J>X%Pr<{3Fs`YiTk@4N0qfLIr3$@u){!v?rj%olJ`Daa?>khIuzM_ z&^|aP<=Fli(tI_L7x-l0`-)J!zo;}l$!=F;8;haL{|)0{=p#c1bANc;=ANe;vPpLR2dsN28N0~((fAoZP znP&Z{V@1lo(vHF|ioLMGrTK(RfcBsWaw6F^CR1dnhXmXQ(pI(7yhk{GHvc z+qZYW{N}nvED}#>Qw87&9qjl0kKeAs@Aa>{Z-pODR{Br}X8QPy-z~PB^}|VOxYbKt zXEL7rmMhO*adFW3g+Am}EsaL9`2=p5whGF6k|P)pZvj7P%b^j`?z`WIz6zQ-o3h-4 zx1^=_5?S^^WXrGjG6o#s1OYmf{<8MVp34-*t_X-J$SeqYZ>usB1|E#^(hlS$%9Y@#o;8 zJ0{hz&?j4-Bj12e=5l#}54#ZKZQ2`nghfRl<57_D>kqvy^XK^ZIcW8SE&t?&j^LZ> z%XIEVE9xY{AN^{`7zd?@W&g1&*1{$qT42L?I5>Xzjuqk=Dos9@`34k%H}M?fFTeRS z%(1rDo`EU=ul*t3?cVzX->Smz`Db~%_qJ~Ns_;Y6*9t$%?UpZ2C0WMFf+uFXcyzhz zJfH9cMjIQnW4FxS87OGBW`uZ0j){C{x;R1rjcY(O`gz)6M)lb;wcFrI6 z8Q-TfhRa&t%C{$JHf_n4eSMo_);&fv{G*k1P`dvbd@Po;*rl=~v$U2|KAQ&1aKNnB z@!Bz&`(M=+xDwqU7uuUA3}`7f+_MX#$^F7E6&vXB{#GpQzS?_z&IvmU3N2{q8>$Ni z0q4Rd3M&?EG}2FfL<@|?l@~b8#yI*280EsTN0oW$BVpm>-&(ulPM4m$TyzgW_p zO=9|#T0=*kc((6ZoJW5j{;+0|#oM_2(6hep8m)Bp;zLmphfv-~Wxf*FAH0_Z(J!qL&}?xQ)N?qWFvF{p^;n&-}$LUz|wn)&+3N(m__pIF;x^ zAl-4EUicpvmuMAZfZ<6t(fnl;5R&JC%3C2kaoW-ZsSzD5!7N)$Y{%F@k8kain*Et^ z&PshA!y1j3V4~2=&}*BHQw`jPs`u?WWhom zE%#%w_zuIv>O@sR(mXdDys%>*`lPS-X%&;@hBN+y4f?20CS)Eg%-K3OKj}0#AIfy* z(gbO8qtt}15Q>GVG<*j%On@xG_#EKxzI6LoxLmQ}=qa6!;Jx=tu9PsyePqF*l zw|z_ds+fGJc;b3B7oKPrO9CmzI{PKJ*<|`2hpn8EDC~PuXk6hR?d(76;g{PrA6-bY z(|KfG@ivxii}5^%?MXGej=Fbb&AwhE>{5FiN%psgUfkK9<;Xf5%u21odG;6vb(`soozJ?ClJNyd|;ihBU?R3>ry(ketuXTR|F^x^IMF;QY1D! zpzN1IEDl@P!!;!s^P9o2=4|IDoyf`h1x}N4Fpm_qd@hi;gwGy6*gbgVBfF3L z`Mje!Rh4AvmH*KO(sqr-L8{s#$})JP{Oy8a@x+8PfBOdrXDg3l zPnTMl=dz2PRxg&tVtE?3Wp0TI3q$j>7{Ym#dGhs;L#E_5CdgWrQ-BY$Bh9Y~-FA!= zVbQL|m~Ed&ikjR8bx=}_iwDd@z#-p5_95A%SZsR5CUJ6Is(PQza7vIwc|q4ZIq>#A zg_SZN3*m|n4BWVTXLs}7-QCUmSnu973|}uoA1eecq`hfVPJ91*&NfCmU{#Y_mu286 zlZ|TVE)KddCfrzP(i%C}{VW50+;0v8ZvU9>#_o9($j`u)&)Bi9;=5Kw7vg$L+1^4b zzQRvF$wocm=%Ydn05NOaPQdz8>$cu-6kH;TQkSJE-o|RVtPp(g;FaCmfA90VPxys@ z*wdACEf}Z*@LC+k-R>{_sXwv%|Gw`Vb|3ZpecbZBx4VZ~Kd$oi@H5(WdUWc{{z-w?6 z|M&0yv%BB_+dpdeg3tQh!AY9yEfs$D;ST#$uNQu}wX5&+!wJg@KAhO>4A%957Zzvs zkEcL)Wl%dIDjqSd_;Ww`2hN;|NoP&v6&xFSF-*BW=L^5tp8qoxTRY9c{1}!vg)UQv zh3C!lEd8`>QHR2_JkAF@5Ce3oVXw77| zpuuBSa(dc^-l0&}>M}2;JR{!2{(lPYy!_CQ{Gbjyk0oD)YAHkD;WL~<$EXA@pYWrP zq5$|pzkn#((xpJy8j3B2!KGP%yIp~lZH+=uw}u})*nQb&y|DYlU#!_c(R9T(RRCV` z1Nis9>(A_-$9MWY%b#w$YlYu!FZ}FlUA*YSNl8T?THV%VtCOp>I>EcHlQ-nR!jES= zofK`OlOF-u!DF;K4f4#W)1eGh6znh+#pitHLpR#wDd2p2Jd2IW9 z*R$_OxAJY_E!#nd<~ax#3>$J}MHjaWJR81|t>xxD%mU)}@Gt|I-p5_^ccGe)(Nc8P`p8O>2R|sy z)C3=5vdP2^F>l?gH|eY+|FF2yFh4)F^OWgKi_@ykU-7eWR#T;-4gk5f*LUDv+QA^3 z?5@NR*t(J46k|~?2Rd$2*Tb7>*3s-S%JA2YATm84_@*iXJ55(qssiwe9ykvB?|#RB z-itr{#mQMe7Juy*J@^;9uXdXkCvcv$R#!W5&9*ox^W;VwwsyXi$c-*qn6sq5TlHW?v2R&gj1y zBYbI@k*p7LQj+MsvN3esmfY4vzbN)BYfivC`C1>xVmb3#%$EHq-|Up-KJ=Xad@}M) zDL3PrpH&%~e%;U__H=@G7T??a;K!cj2F^iN1-X;nkZbSCge7s`J6Lb-IG@BjJ;#PT zw`=1KpH187VBG?v;3hS$8Vr7*vk2tR!hH&U3Zy>=2x|QpTFYX}=#In0!YpJCi$|r+ z7KR=Mw)FQ_ngRqkF4O>Q&9|uwB#7SK#g3)>wf>|H49d z#rj#$ibA(CHzVc5Mw5IdXU1978{*&(YVk5kr;*u-4D)6r-C) zF}t~W18%{`*+(zaMlAMaLR&wZJ4+7)t;bF1q^9JPm>oCxnj-e9$G(Z_$8*84{tL$x zJLjOjw$1~e;-osJ@UWxhd5jlTejUtb@;N}rT=g6vq+IlgQ2_G6roCr~QIBziL+H$z z{0W<{Z{?qyC7LbJEv@5_s0gs}G@T|5z&3m0{e?e`;t#id zvEI+OefiuUPCWJjk5v5O1ggT%Z~N+e8W>Mn1b8MigZa8WPLSqee%zY7B#(C@FH>wR99l`=||$^1YmW&X@IN<|Q%> z*1{*Zbq3)y`I+uud(lvLUwjzg;YVKSLzJug2l{~Dc+W@$of*nZPOngY~P`Rsnd0j@P&R$6vGi z>)-UJcCY7`JG1!1ZC`$|^Btf05S!tAx~cE1lG@FJfX#D z%}#E9hU=^`kPCB~)LIG)R8Hm@p%$+sicKyy1IYBA`(jO;!p;|+qe70BnJ8y{E&FT_ zJ=%zoj!nh`3!mfKo}o))WW$><**Ea)wdN97wAueW@-gviKF4BhEC^^ZO=wmLBRp!O zXUcW70WnOewEJ`Sl<-@1bzBMQy>wFPKsZ(ARVVXU?*VpY3DDR!;3^a@|aTD%e|89@`2FJXeIGp}0TKvDh{kc~-hK_-mu zZe*gx+Ghgm`vMc6S3s-+@CqCx4w1#*r+w^GDE{oW?>!ZN{dvDzc-|z6zjh+x$w((9 zJ2^2APf*-=LkNo;%_y6!gUJIGrwAodq;Hspaj6aOfX%3_nXNt(L2jDEH zE-hOROZsI*Ki%m>nq}tHi903N`A|4*sp|DTNuOMx>x`!KFv9Vl_1Z&W{D`a-QJ~zJxtMNha|6=2aHMsASAuP z%$JoW`%#46j%Tx_K`35MQ+{)vW2Hwn_<;|i z0DR#0I~6?itKl#vUPyBBQN)|5?@`+kck}TyI0@F5Z5h5WaAIgI{(jR7xaEs4c7BTA?}y@# z1s^}MBLXa}gM8dp`Y8VsqY{iZID31%xJekE3?j9Jhx7o8nLv)Wf`xw#t zu^`j=;=DPXbdslc^ynIP6)Usmu0xJ?wo@M?3^9}^$gG&5dldDt#_~ag&HnP{>zqxzv{_Wk< zC;<7)UoZarHg9{j4<{a+f%58SBw;MBbe_`LihiaO)7pdpID`NxW1$+0^0?x7hGfb9 z5uCAzqqAfK{Nedy)*ML1E+q z=r5`@>+CN5|7Y)AVr|>D>!5wlJ@?)BY@QQ4POQWjDULBhPJjYX;3J3$5Nb$BR47nD zsqiU*h=K$X6bKO%NFh*80VJqV078fz$>NU?kPnG*LV`kmfP+6T?|aX=v`=q+^fBjL z`|NYyeP^F%uW`XE;$%EY=6X`gX!Eo7vE~~566^AB#1p{(@~7}UK>kB+rstS$GBHLW zuDW=I5tN@Pkj)F1G!}CfS$5%;qKn+8YqZUq5blVlj!?%zx$1xM@B6{c^RB#~BXHjU zzMt2nTlEKi&%gZe{rqWPJ^ici`{~Kw{Jvjp=CJ6@&7T({FGdWgO%OShr4$+>EVdGN z5zmhH2h1ES1s9%xH|oIh8_Fr8LhGcT0IodKV=xekAVF#@Y?j%#ttix_H?}@Q8&oN32>c=QQk{!5s%=mw{{s| z3u{L4CjBHYVE^-P`|7h;-H6XkV5S@ldX$5)W|?*)`}t_6o?@BHrc3JBQxl&@@OEtA z2Jd9wGwqsV43}Lzl3^8ulT8UGNPNLfdkwCJFYMXq^~b%~^l4Mb z8ZqXv=UA~EyK<@>Y!UMZt6D&v%Do}AS3Rk=)2FSHr#dX(mUzAX6gu7)opR^2?@7H9_6fuJiQIPXG}sPx@5XlRxtHg*WQE{}zk9 z#An;oM)ECpd&<^0b@^4@6&(*HIVOBTo8~1ynYdg^;yR=^(6u1ptJ?araC-WOwzFA` zuA^l4ADDDsukaW)n?w4@ku?PZ0wWeCdrX5@++iuEt2RrG-;Qn$t<=30KQu~h#=Y_S zlW*R?57^`M{t5RD;QMz?)FS?FrC*E9-1Mr+zQA`K#aahXp1-^pQ

r8fk#=*+*n}2HzAh)q0T`+l#}Q?jmc;M>U6sYSvVJ}omtQV< zS@w=Hv&0s8>bsQ;Ba?F_Pt0Cdkz#qfnz0rCkfhZX_P94-#9Edm?Y`7vyQ~3T;=^x` z7`D-+EotZ9*0eXcZFajJ3ETeRq^_7!5135`ZO3_VdQ6GsxD_vcX^8j*PMLwC%WQqy z=plyn$puy$xMiC>aQRWN{vw2BZTKG9^nuL(I#!zus2ec?Vxt#kU1xDhCy(wv5tQze zg3wYJg}s;ojKUx`zSBcGp#-#U9xPm|c<|3}ei6qX`6>KeU<6k>)=*le@32`lAWqpy znm(~ce)v56Wo zBxX8wlQI^zu&Jmd-Hew)x$e& z0=*Gj8$yOVK5g5(idE^fGdcmgi-JCZbN2eKL2Bu$Ma_$w7Ei8c)r-_rr+n(=cH5SZfU#yBe*lDjj~z7c_SL?LD>ny6@hG6MO_q$e40XsBl?b9O9WT7p?HFJW zz0k*{?F~1U%a08Bhd|JV9u|a2msqn{hr<6GX1j34Xs)P61e#|NX9}Z?QQJ+!m-MK| zdUqOuOFYL%1a#!}t4|(&%P;=i+2M{n>j>O8fX_Po7s;`peuR&Czl5iLU%>H_zwZwV z$lL^SJ<*{Z+}e=UIuqmA;L)K}(U;Ff3wEgZmmZhk?!IYn!6!Ax7UQiHE?=L$#5;>1 zBTX!kV~;I(_^}6{Jj-j@Jlo=RRWHAjbp|RUp}eR}{*h|^h)$1PevBF0UaxlV`1Sbi z*-C?Be5ZY3PnF=ks4Y*(@h7s{zj6;d#Gw^s2RAzvnpIP7Q;Z1faoJn5sZM_Vg)>j#{tQ^5ppWqV^=3Zm5n(I|${v_}hsGzvt)hdL2&9M+pjN`Iw z!Usm9F~(6idX__Us*bae;un>8yAP7FE6a&AfJ3a!9f~@{nl9V~erxl+E6+ay_YL6l zUxRJ|f9jVL_)GYyF+Tm{1~8jHeZLP^DzUi{H{AqkVQeBX7vOUD%u6^-6nb4~SY~>4BAhVr^&vj*$Jo zn!9Y0c*=`t?gZ%CC_%60RUMoz3|gbL@rf_H9E%%SzSy)ShT=4*HhsORTR-eUz7G1z zKO4m5Gh_A!vHG}dtItg91{YR?C0uQy9i?JWP#mK^D@Fm3r+U_Q>kRXmWGrM{qhI3m zgE-V=q|orOtqAIFu-(-f2*H^%a_Y1>eBm939#vq7jS}Snl#*!n5l3RAQAafO-~glk zFB03A!tA~`UfrJpW>DUf`n~~tPcDR-!H-c;zJ$%+$JqSw2S4%y9^Ck4^QX<=w)4tI zzia!}<`P=3?O`e<{5DQwo?k}U?3;2)7l@^DG;hkceZCWxG?(?1nM-_EUaLOGJa`<`7V*l z%(bb5p`Q@0P2GyMFtkM<7qd>gYGcst-0{_Z;4CszrXa+9EQMjZ6m9C$+;|6Ch@mt4 zj4YqEzD~^OORQ$U4xj39b?3pE(OssFz>K}u#Q`plO1}K!WP8z83`SH?SF$s2(HY0~ z!c4HeWL4p{LP1e*b}iF9DY${$XVQS*p90Q-dVcx70et>zpcdrmAKxu<1IVX;+4T8) ze|$GtPyMRR){PN@nP;9xIqJjo?TR<%|F;`fEmrK$l7K#+#c#Em3tQ69NLkl{v5rjd z=vxitinFp=3MVz4T0W48Z%1(_CKm!1LVc+X-S}Oit|rs$|3{k9-=?C7fV$z#uC{TZS245Wg<&Owlb1N5yfMM zm<8m8S1L}{MXdO6tc{pI+AYr5oH4lkxN$3aZ3GL`Mv)sr=zUbfxH)74%3m@t|Dxc; z4tLdbF_+kq(>K{PW;D`ny5*?|vAx94R8^Wc`qom*DyuGQuIax92m8f7s0PmZ>SJK$ znH;9qLEnsX-|~z9d%NC+ z=N*Cj2Jm^ue`VKVnWumFrOz+%C*SxhUwCNaw`#5~-2Bb5sk>>>HqwIMN9k4<_O|x2 z1DNvAhdR(j8}>csnPRG=Bvxwr+Fntn;Sl&}itgHrd*a14oapEJ&5ZbvDfYqTpw3a0 z>$LCk)STsvB>Ro}EgeEv9i>7a)Hf#_4#yX?v2z}8aO@wRU--+f z+R~fFY!ZRTSM3Q;HM_|ELy2Gvr*2V(VtwI6)UCF$G0RS=V}vt&%yteE$<21-8waxr!~1(iy`+ zLR)^qW2|NzgUYmV^N;viZ5&$ro&Bnwd0Dih1(RfpOW*!t;Wc?ukt<>OYGEZ z^j8Gi7Gqh!zJMn*<@jybrkK$&VY9hNk8$Zb?Ueb#=#ZPymPKuG9dGyqOIxTj2af}$fr(AL8Vbtp z99=zr1&I-vHA+<$HpTWnx2=)oyP&s{xQ=nH=J&Y27YEzRZem0boBc)|oig#QF3#XX zTpTGpvC!L<;z+c=aAhoe5txfbZL#P!6ly3W)Dla#g0@88*xejQPjI$}e<9@l6tG6* z*{k~o@Yyc`8lVN28@`8!FX9J4*2llK;ltv=hj-gkKk9HL{&^NI29K3X>m@j%4Er(u zFtx)6hef3g63TaTx&E$+HvqQ&@04pE-XHik_1;aVONH7 zTBa+BZQ3%Olr~GiDK(jJwmta4oiGVHigUsYbR;^&DY9xMbk4#}gXhMpc(wVGO*<6u zs^*SmZTu{IvzE=Cx%AaB{Isr7uZx(g?f?Kl07*naR1IUE7&eza;ip~VSFB)L?J)ww zeW&dxdk}TzNGw5Go#^LciSdGb_BzXPYDVG{!0)LIpu58U)kctMp)j>EY6i#43V^E%>YlT>%q2?JBS%c6>PU*TUS(^N+xN1Nh9> zK)?4-GZw!u3Xl5uvG3Z{m8{idE-7znv?p>;=Lp-3y^0Z{ZI9D;E-uEu}=4LJDW7mZIWI8=>F1))OkxE`<~%nJFYdX$+*h3%sd z-RL?Szg)8b@(CdS8pwbBGk*wA|D3bCP95&jOJ+2UgKB3{Q(&--nB#aTrd*s6%LvYL zZcVWq$vGq^0c(_U*=vA`Wr|oum%~|hA4PYAac{hS__6!%1?&-gF5$ibe6EpC-kwRH zYV*f;i(E+bv`;GvKSa1FnPsU%p}t|8vWyqJjoC^I`>Y>uQg;p8G+QoY8a7sYU-x&J zzvEJ^QN8FTVVcf5GHpja{Ma^f@xz&Mf*t)Hi$8sC%g(aJ8HtD39h=yc&pB+p&GCy< z_vswqjYwjr?nVyJH??9=cM*;&?rw_47vob26#wEhHFh|z5jf-$=U^qK1E;zX6;pDs z^@6(D=s0+4w;dWqBKt*t zj|YW0z1gFTCt=VT?I;_g4gbO^IdRBQnSvZ+VB9*2C~7#g;UA075{DeL>NKPoq;#@lo)-Jnsm+eBSi;>^-|N6ff%uU>*TpZv4iO^?V%IN#x{2psLC|GzL)t zi3x`qufZ%-Y*Iyg+8gzK+Fh6ZfOjmnO-H0MV*3Kk+Zv4=;53*4yZw4jKc7K5OvVm+jqgaQi z;#OOxy^2Kildfz9bvCq*qdsl$Ag^~xkcK>p%{DbxmmFKg1EAHpKy^>%^->kFvvt z|MrmtT`|nl-AIBsu6fqwmas5N%*lfEE=uKmH0pjz&8%os-z5mDC(Pw~ubyWF?oR-p zXWW(b-{YTs(*$)R_)&pA0*0D7)pdk7w%4;VaicMfWCMsop_PMeS}Gx^&90d*Jz!RY zW|g7stKn%|sG)>$)zx6CdCC!|C)S_QP0`a%);VY}9;>KrpFCUa(ZkjLiL=*x)EQy5 zAvqYcx_npxOsa>@b4)BV5OvSa5xe z8(%#cq;H<^<%u9L?ppYSPqwlPV{Gf~mnVUVdwd#Le%j_uwM(KeM$UU$DC{Ej?rLn{X$(}mwdAlXs#L?T z?Nbh?;(`;$8G{)8S-J+n93_aG^N3e5;}B2!DUyS=Jg_OMZ@3qJ?3eyb;CuZHBXHjU zKEt3Vr57%0o-TX>SZjthIFi@NZlI2-mQbFwrS;@%p=p=GHEp00mwqKBuoe$0izJiP z0uQ$M=B;FpK^xv6+=}-Gx5vy3$#GVVQ&K!u$M)v9ixD5yIBhy#G zTUsrl^*G+F&AiQDcWgMRJLCa~daRq@BXvaLVSLwaU{|CSR#M;B(3&10by=pWo(m|n zUnrsb|H{N9jX_8*;bmaeIynLw+ib+Dl(>>*_7g%6pHXs#3jCCHJo3&$y3 zlA2aLjU%XKk<$txwybiD>L}&{n{dZrUNTZc>uVPL{xPt|@VQR8FP|&_ukyTe=HSc1 z`uJtv1ag4#h@F$EC)$mXqg{)S1Z$l9`nU!bB0Mpsb-&38Ke1V}=wq2_G<@^y zrPvNfH|AlP_*L%YoHof5K%A)aR#AeSLYQ30%s)(FpX(xpn|cyYxj_KJKs~?W$79{c zLk#;GT=CW&JY&0&NAAXevZBPYwW-Yz*f8+*k(tQBs6g(gN4f927kWWmnw;HFhJe~O zDkzO%54L>-TvpF~dx=|(-ahu#;^8}rZR?K+6Uuj-NuJz2tb}}!|UjJ=13M&aJVAVZK zuwPpg#P@^Z&;?lT>oPk6736u%HxT#oY$I^r06yE$lXA+0MEJBRtQIjX_FZf zmlLyQ8pHRTCAaVvDeMF-0Gj-1TiF%s6c8a90w=w=CVpzhXSwieaI<;YValyp3X&5Y zzVn4y=8=rfo`umI+PGvs+A0$*8nthE;QWPH)xeif7A@&pG=fH?=c*@~5b zRm12P#rWj~m!Zs2tm>&^ZN26_k}+5#(RU?{Rl?w~|o(-cr4t*byI|Iq6gu9(= zQ+_DT%^U6IrX$=4Jc^Dx{8poAbQ^|i zi5uQn1jc=0;ZVNB=?5^VY>&H!apOHh9b@KAoqCCkQjSyZSobQ zPFHw_;&x0s$9KlNme=(;+1$M4IN_Bw_-e#LmPG3f zZNNixxC3xdSIUyw4x!~6EZK`RuleRkJB%HRa$ze2F@VBJryR^UO|-{Gdyag!Z~ExF zdE6atarkkrKhpI_yvU(PR^aJlU+VtYSGyM3kO#Fs2A*|)98A0OB!=4+EWY_D8Tx3u zx$c|g)vt_4@OE3H$x%UpPb!jTVYt{2^~P+ag&E}}XAR@`yMGPVcl1_ySZCPOUg8LO z^{3&OiDTiK{^qh~vH!G3Mh%kq%xa-@;>9Dv|FKIpMo=+Q51-)G3iI3|$LqKZjKUa} zs6fSmd*Vlb;r^%LdRX4Eux&ZsaqeH;={S2-Tei!U`mDGMuhfW5YHMu9ybYaICR_nw7Ugj4kf^ND`|p5m?{ zy6}r{?5!WT)g4qG$+J10Jqkz4(u>M!c`ftPrFm7)UR!J^o1WYSPMhQjgvfpZR8M-8^JnsvFXQ+ULVfs4I}xbCtmAr zU&m1XiLskgr>w4bzhu^!(10R4c{~ga!FUnd9KX=ZzWj$_a~k-C$dB5-j7?y^1ISB2 zcZ|l`2vQ?1Tgl`A5cdifRz77XIbDW0VnXg5cFcW6zO6a4UI(oz7oc*LKRL;*b|?x# z$5IV~@|Dm1DWFF7Sx)y2;Ij<-Iq7;wI2Hs>noJVboH%sN1gbFP*gPi|F&h%2)Z!|P z<%+W^WvQSZBZtE}c&_`jUCGVAGLCpN+cEFdr#wU6!pVs(_4Kn` zmP^6{Bce2sv|jHGvjfu-_Z!%H-PVGpigOd=a2iIll~PBt&ylb!Bc}OY}A}m#nWVh zoWjW_RVj?yOknZEr>euY|H}>IV9EuM+s*@5)ZWDcC@UJw#@Ugt@z77 z(%aT6R-`pniDiqm9fJkH2{W94CygFgJ_c>+GX%+XFZV5NFh=KsvAOco2^^u#5h+}v zl*u7YHDzMdr``A!X8-u1uANCg4Xh`0>uFz}2CmJVpXzZVI5vYLPJSZT8%DHm^4QpH z4AY0TJL-OAjoWMV(Vgnb<4~|{p&(C}k_W^qpmB>&>NB+hb}$Pg7J<38OgVoJ&Rtw@ z@>_>*hJhh9JPx>iQ*uiuf&8kYDYP(|6Qq!83t}*g z{2k7A$ug9^VR5M`zv27&9go2Mhd|!(fG3@&L^MtUuEAIsup+RwZd?-;)Sj@MqWnJ& zMzYSQXjppc3%`7#Mn8Uun|8h%l|YBNLP3FfgE+PG10k}_n2_~l;l!T46{z#U*0GXh zBx@TmHfd`X^i!Dx#(9e}UEk!!&^f}LdQ#6ia`;zuVx%q9DW{Ill}=my!rSYk*KVao z21~~oE<+SCU*fh$wXsMWmxL2wuX!INj_MoyjT7Y}$ORPkYRbrAaR6XD#sLGav6=9Y zGoP5O;TDJlF5u9r~vqLDZASW>EvZ>PFF&@~`hgJ2UhiRxj2Ib$u4QAF zeQk_KCR+ZqBM`L4;0oFIx@tIkRX3jQkmn4L_=2LVTs11$O;Y>%7lt!B+N{?gdhzvx zov8?{nURt1<#|Tn{si!O#=Wxcx4z#XV?yW+H783BOcYJds^Mn6{G}gkG?fpNk&iPW zO}H{!FN{1F@W>L$RnEeEYDhkc4=FiIcV6N}XG|!rbZ*<<`@4R>H>jjslv-nU%O$u| zs69%%VPxx$My4OZ$IH?Pe z8)H}izvRLM3#m?waqFv2-m zK<^2cN!1u*@!(RkYQ3A2BTaty=yzw(S+Yt|rdwBf?k79*o3{KN7cR%AfXH?8*JLmX z+k$qaiE3$MTLavf_wdYCx}|(C?K11gd((-|!FG(Gtf#DZ%%I1EHkRm%ownG%ofB|? z%YT3nFQ_MMah9}4Nu1rjYxKB{#FF|L|F-VC^xQv3$}QIZfcYSz`4P#|#|hibqyK&Pb}9+t&nS@nA9dlbssS-}gB(vB&YMEZ%5_aFmc>}<5P}5 zF#dBlTNa=Zr9q=8O)|+Usi90x*=#*>?8)=680@%LMz3M{b8utGG!DJ)(-m=OXZ(pv zmYufrnG${MXIa+5i`SgNJ7S5!8a5Wii-shfCtPuY+e1Dis;vC721ZQr07?S?H~+%V z+2wwIrz3FR0KU`V9?38R)_kz^3Nzo|0fbExjuXg}NQ*+r%SPVhBL2o1x6)_%b;IPoaT}7jvPGq1^0JN->rJO= zVTw}r4WAszAYoB4hyIYg$h0h*F#iC^(!(cy_~^D`lba(S8#9M?H}M(yWQ)CGG`8tA z76_$*xU$K_Hikp06as~(E<9%N;NN2eJbzSgI=!*;Cp{d3b@0ib zQ;?%@IC&h6N7m34SD0ju`Ob5U;43DH=8l>%wm1yNc;yG>XjhEJE5L}s0LcT`u^dFP zh1tOKy}aWQxPJuvjt5-nH~}=3SV2GB z?dwn#(Mp{cM#s(c9aosH4+?0Ebt^oxq>gNkOxr0q>+~yEExyEeyF-L;%?zG5vW+_X zqoM&o++!@8;vDR=4P-8TNh^XSTXuW2)HBl2|Fh_I3thT)&K8n=0PwUnupu(=K|SXZ z!K^;><2VdBKLXSJ53e~l`)Z^Ev*wkm3z(1UIM3!s#ky~JL74~t{KXAQj4Z>2?4~Cb zHSz1DC=LKaa;A+!{F!~N4n}N3RBVaah|H~kSW5C5h4EpBdRQ}M2 z*L7|lktIBi@|3~bJ}QQt_m}S)*Bpk4@On~bO-t8I0$o>IV_G~X7q3NX%)`de2Zp{3 zbuSTE_Yi3J^3F!!{si!y4fUH7eS(?Ir-18tjW^FYI6;7Wk=SK#3Nf`fd21rgng;&U zf0C$)Cy1~Q`BN#OI5OG`X90)b`#7H&0J?;_|h3)Q5dtOAX{zg@f-+9ICh@sThb$m=J4utO=#zAHX)ALKMnANiH9 zayX`9SB~a1m;7<4Yt+-PLp|+tV8f4{H4AZ;4q3p!V|N`xIc-KM=hJk7HEM=LT?Lwr zV7l%nfqc{Nq4UE5%5^<}YwQ?Pm}6hd@M&JjK<3a=CLoM9s4Rk0Z;I$`x3I^yw|SD` zuRMuPow9J8?&Y12K;DqN^TfZZ6Y|YazpGj!@y%DCK78xd>xWNt%#EObN8pE84fGK) zz8%~PCsqo+WAwW$PB7Z|E^F0%f~ThlnrKRn^zb>(IhgpqE`{&w>c8LZLE#%Y#Ur!$ z{Rh99lT3!73?Xmwl*=dC>|7is1YecT*C$d|>vj&kZ`pR{+i8sJ!+m2LI0i?YQH7oC zle=>?Oy$=@13kFL9~$GbsDD%{zrf&8o=`iU11aY4S-C0^Y)dk6>Q}O{c|ON@3`tB~ z?_&c;Tc?<8y8osDa^fu^SX<=bd;=RI2?td^+erko`mDLfOV-Ge0eP+YKCL!>p>z3XiJNPMiE|mxm#n9 zl*kptDwNVym#n9#^rPN>GFqQLByD;nY`+8|ZNzA}Fu66Of~@p$Z3BdOrjCspw@+}q zDuDL2sq<4kuH)YD@drR4V|@1?1VLMHQFvXrzUK!*xLNdVQEOD$^~4W0P}MHxw6Aqu z7kZFqscMo6;hjU9?jN*|QhPISbj5&12qjO7eA zzc~?~{BaZb3658I@|P!p+Vt^B;A<=t;P7c+HiBFz)@G24Lv&0%*<54RdEx=n^p;+2 z5&#)kL|ViIz)33|3j^)iOcE6z`7n8V6G%IAa6*gVI-5bR5R99T#mM1$cmLA_=F)mP9dkhz?XkWjzsNf zJGw93eGGr$6%&B3>&ii?ZwbisZNC=CVmSeZWkPP=bCY#JPp;*7A;IsmouID1>5d&u zLVh^qHLLIU8NS;y5~M+Gqk|Ql%ii(gY9p9y-iX5%YQA$UK!U1vb)M-5AA*f|$? zKkz!*9*ntzSIrpwN1wE@1XjF0uFWAuSjYrYV4#hK`K8s4t}5q%iE~9ED9giiiuPc?6QsZ*ne~#rAkefplIr9tHo96agi))U5gM-1_&i>} zt*`oDIq7>o-1^2x0%16{ElsH?1VRSaXfla35W$nChSiCo@N2R1}9mb#=B3wFV_&0NifV9MkeG@f?Y z&VVJ!(HtY0*EA@XPo8s?yg7_RH=g)kH3aT7WOE(4?PcRyV_*Bw-hVodwmR}v6>$e1 zU-<#IfBCU3ocr0OB!x*pkFM$wQ|&5dA&C{GUQPhQuOF}A^AklZ@?2Rma_fVPoeJ`8Si`IWyI z9X}y^|!2all}1g+4r`Xd8`a7j(ZnCrsf7xNJ*K|pb?6?b}t7S3jalI(A?ca}j+Y{vBcU(v)F zM7EIgPUZtnFnnfC!BuAP!wt6#H9|40n^%scz8rS;;u6v#iYK_;WUA0W@Lry61ny4&pKa)`Z~Sjz z!^cA(1M|CpwfWN%KyLQxheEVaP{)kko&*B(rjIq|7wuJZ)>1E^Y{kboz_MYBXB*G< z7m=C_!VEEh;>fq+p|YQavP2jA%8+D44N_6J7J;59ydim#`I#bq8H{z(VRf)bz5$U{EM zR?|V?s1Lm9(*s&QtW?U!!75f)8zt$>@ld%vDiK12pYdU*kQml98CFNJh_`621A}8PfvmFlUCxCv^hbRDTTQzg(jUP^W?h#%2>u}nTrJK=-iYCJ9K@po8+nNCObT|g ziCaw}BjFqcA%n4Ic-APwEjGxzJ7xJ5rV6K~STYwApFIibTu8@sulNlEr zJLudYD!jqtIl0|yyr+NQ25^o{++P9b_x!vW^d?Z>`valB1BmO%r-8hLyw<$N9E<#l z6<%_NXH5N2AOOn!lu)wm;jv{NQPjSfq$5>Xyz->YjHjZDP%?w5C8+!UVk65-v5LY9 ze0eBe#kg0`HUjqz;Ij>VW=1C4YDk^}`X_?%G!QGpOZ`yD2ly!XgNJ+!+)oATBj9~A zh`5|=TnI+Z*{4YlEb|!RUNU)wdLfZle1T`Prp+cJVnQ>HmJG<6=v8xJn43WATx57+ zEA--poN6l&#-X_v5z0?23TyA-k)96dEk(77ZPY*aRf{g-FlP>im8^!if3aQFX<2Mq ze3eJtXE=|m2TZXA;uK$I?5nA~VT}3$U;c|n8q3jkbYHr2hre*gVS_nNu@~w=0vLYR z5O8}mJ|+4#4|3mQ9drt-UGcHin(o!vC;-|>RS4UMC&by0WjPw4vjZ$5t#%OTjT>c* zikQhAJ`yQJ;}x^)ZU~e#$c=?A!ivoKrC8+Ze_aP=O@8`Jn^e+Owjok`w?N`GCrT>J=D^Iv4!gX*8Qv~q_;3NQ+LB5mX zXhOKj`zqFPwsm^Y$c>$!07ho*jUexkys78Mby~}DbFjPF-07!)$>;T{Yt9_8@3OBu z&%EEY!Gs^7@^>)>R^@7dDA!Hx1 zvrwC|z=2VJydU(w!6?%y{v-I_-ii$V749(e?7rM+4ZE_HYKHD%-d|5Cyx(te6K}@3 z!elSQZa;(5eoNK`RQtimK+T5sTtQ~e18~%b#^DFRE{?ID_8}w;nOkxsvF;p8OrE=o zh|6vx=J_it+Mpa%DqK~qlh3FuSC_|@X)7cKhE`h@!ZD(YGx?C)Z^`X1n+aM^LJn>d z6FQmBmJLl@4Lk7|GAz}l1d9Xpq+!hs;9p0*cvIq<&5hpL_(kXU|0rX5?H%J`$ls!3M z&<&w$A7EvLneOJOu%;dg^KwjLvkPn7oNOMNN!rymP>3|r!>9QgNtaBe=TeL&`w6sQDLJK zWIv*tU!?d{&F-c&AAp9T;)FvxgZinfQJrZhk~rkCh->lIdE>)@S8SHpjZ&*?FUd@! zlyxOL!otmCdpIt824KK-zf}JAYoYuZ;N-1t>V+py?#`^$uHIO>>WoHz#&gL8(Vs-ktx2WvQoFk+^mRlBm?n?SnFxmEoTkx5|FoJDN5 z1+HteSj|;__LFT@D}?Z5-L~kgiOfPC%MmBG+1P+tq#BFZcUu6P9VzmRv&q$q@R&XF z#w$?Ol-amAKw4t88o;2-KJ{YFDEuLiU!KIlq3Dl+`Kw>f8$tgth;6(&+$eIs=E)kv z@<|@AscpQ*kP&x15u_*Y12>)PFrOGQHgP`*qzy9ek^Jfzvw1bxK{)qKY(KVL98F|; z7neGHn2T&N*MowXl@4USHxf=_?m~F0(v8;i48lybghpOD361iMQ;Z8nycIq*PQ1Z9yasR zX8Q=!WH(nH>mKpQFs% z?^d}oHP%9Tmwtq09iXqUeFRqnEEC@uvwbat+^on706e6w8B!!VwJcugQzpl{eWvVr zCql(RD=LO_P~YwH!4BgR2lon>k8Ejg#Z=>V%;^#bHg`Z3HO6v%l!-E~th*eaqKMHv zQ-uw?%|${r=}TMoU}}E_qm{n|`j=+@FL~l^2e0CaAN-Pjb4!WEBPdR03tVh8x*;V=G%zct#ue#awl ze**Z92YgS5T@L?b5=-7$S<@!(>v;P2#ZR~ieDUxF$b9OT;{{~?j=&fA`1gaV^(z4C zUDaz$EWP9UfEz*V^Yy2hUf`XbCn6UHzWdW^K{NBRDHm@E`dwYMje9e`*PCbOVmJH_ zkncvh@N`|i{T?yz7*#V8xo(HrqLZB{GF!?~5B@G{Eq0@wyRKenEYp0}$0O||o8u9? zN3_PG@L=@eEy5=`9E~0iVTrhp(?TiPZ^x|B5_`wBNnRA)N?VVStncX5V)wPLz=;o| zuY2x{>cb4MyKI_hyr&T)W3~5T1>&`0E*IlFMvJdW(?nh|I3RvIiY8MV%(d!|8e=M_>Dw|nP@>Nu!eTh^q}q0((N;09Y>nC7Y{(qR zPf-6gwBd(@I*i3deuayQZ~EYAAfNVS6If5@fa#-P3??^xTxWU3yxs!CHI1wdAFbe0 zUf&I5TYQ~av#@QF;qQ-;gPO}TuOM0Lnh%{}kYn0>f5`w()D;^f(Rlk2R`ufI1xLjD z>0OM#eFOL|M)?UjJSQ@d?7*jhUwruj2R;sd_3GinS8D^vjo>RR@Go9?6Ue86eB7S{ zs{~~1=0MY2x0mZzS{92Ei`Wth0`PCOg@p~sNyj<1Ro5K`0JqADJA97f)fmPxai>pX zEmR)q9$Ssauir z38YORgDDd8ST>ia$U+^5fl$~F#?qh};<>KG0)$z0?9G%RKZEVZ{wdw!a1drGU1*(@ zYXOR37l6Vmz>KxYv_Hlzd(&o6g0tAhGq1}mOvzsY{R>!=e`qu9OkO8%{~-T zv$C%AX~wAiVh~EEH)pfvJ1069hv=yhfn1inz-%#S1YnMrAlliy*}6(FqMP{A&lvxtT+VM>G4Mv#~uUR*6_JN_o zD^&@noE8|nvbYb7vjxvB$Z2qV)PKM=`2>&An0~gg0W5q$#$7BmSAnQ)Y`|g~ z-7yjC#MvL;iK`m|%z&7nO+%i-lEs`slvp|{OBU?YU`OqsM3O*qpD4JL-Ej#msTc#Q zaVg4?Nh`y#<}&-OCbV!B-5WkQYU78C<_+M|d7bhLqInCf?*TFo?tj>|`Gb+ZUXO8R zOm;%|6u#qUi`qaxe;1JLu@IQ46l5H;@yC%DNwlPR>#|Wnku1INV z$`c)gA-M+TIT0-3~}gUU*{=BBxf|O&OfRV@`CAC(FXEU!9L!aSr~|FEKlKj54GJ0HK>( z9#vh(Kl&}7lUo|hK#@rj5hwq*ZOY=;iCo#5(0t#F)gJN4fj(sUZaNjx%uS5t=X}Al zc0`|bLR7$N7vl<1-Tvt;ZEm1>;tFg(_DHcSOy`_B<#HR2#Zb_0hVv~`jhKK=uW%VV zMm)qQglq0M)&7;<%!)rM{xAri0`l+^K(5)8t#fQ5FT1gf8&hSSPfcvv&+@RdUvOAH ziO2TWzk^-;3%|x`t9~*r%Lmx}{iPqefB(;;@jU1I6Ts&g_s;64fSDQ%x4_>C_;qXo zzxdJXhmY}5@XJ>(9$w<3+k71S0``0itnUNz-8_z0d@IQJg#8o{I=(CD_j>qVAl~!w z%~zfR!X#aZubLA~Z}|A$Ez2F>(d`1ha?KYd-bZjaM_I^{7};9JdgbE%?O4s)9pfsOX_DvVz^v#~ znta8n#++q%ab6>~!w)$=fH)g- zA+qFT57UCBZkvQ?Wi2(4)qmEUxH+J;nQ#@?$fO^wsov!{x(HtS)3+yvL0pc3!%L1;f_F-V& z&HIMTob@yiI_@w3ni)k=1P?ezk&Q3B?jgo?F|PZLytcSx|Z;46ZVxy0Z@fTXvokpWeuZR7+j8!JLFDU1LuIamw4F!=?>Joy0E zVhyfd*9fA}-!Hz3By2xjG+-xuM>V%E?W{!@8G|!otckH^iEN&8 z1{VQDt+`UnFY!<%@&ShGT(+k@VcEvG%YRAsoo$Q%ToDmhI*$rC7YL9RAM!I#G_QG11k^#(AF z?CZk#qrgGG5(W_0a=xkO8a+3KyaL=1^5cHWycCr9dthI~DHwBeDevzGGR;`qsh&4~ z^!BEc+p|e$yp$#P%@UJp&Ntrtxqk$*y};>9^9T1||Ds`e&J(Zky*%Rx96tu;7fxdp^W-EXCgWl4`6n8lh8l*}BLg^M%YRAXEM)lwBPXS02ra`H*>yB~ zMN7TdiTW0G-AD9T@#4RbB`qe$>R2Ww^E#Q@KE8%YYIvSOA8jMAJk-!;3a?gd$;p^G zAR7{HW%((d?7NTJAS2x%q)jEay?ouck*kts-aCeypguXI+7NdSYh4bho{E#*1()HH}ED`g+- zJZ0MX;N!3Urk@~mFV8pv_a}hQIQU7;$?)l?{8gdX@21H6$~Rs;eBnbr0pw2t<0pby zf5In!bwI~|>5RgncUi0vpYO%+77;c6rZ8ok@gcC@;i1AiO}y8`XTtqaaJ}`L?*JL| zSZ0ftyrio>$*Q-4dCte$rPc>4#X08ud~{~gsC4ePiK|5ojZS;dIu=H>pLUk!Nvhtu z31#O}YJOn*Skst^iB<#R6C}1j<3gaB7}ga&E)QO4Nu#DaTA&0w5x2_JRY%15IPjxYt`sm6&7 zKnWFMHBaz7RZ~rv26^1_gIA@9i&XVD2x@}^2uA1_=VpvCY~z(<>-=#qHLmgG$$}VM z$63?@;P`L8B))d|o*?6p|DczNG?tkHQs>HY-p z`NqC8AD;rYp#}d*xUYQk6aR8(d?)Z5_*gi9DMU{L@zcWjMbY~6aoEZEi6H9wVG%yz zBc@**%|!vSo&@S$B^Chc!nqhwcRk--`W;}|$+>ZGF`(^KV>yYBY+PQ*MPD1I+&P(1PE0NpCmdm=glk?H^gFj{S(o$l zk6c-s9$YzuT3WiP(NnYWErSv{HN7Gq>t%85w>tB_tFojsa<*M(9pSpYH<-3pU{^T% ze;NR_2tXXWm<=BthKEc=RK=4(#TT>83ZBN3BDuJ#1j?Px_ANVgh#jNiB*et&w|vV# zp%p{Hokw`_)$R}%$1cHH!i@#)j3oT9`4EcTu#71@n_B0)XC4< zJnbip?Ju9Usc+1{!u1$i#1B7w_eCK&V*;8E%*wrBJfuOxbqYu7X4ABTiPjb06a9=>iY8bmu@P(8+g7m|ouwHS+nx(&A4qDRyjEYR7DX1DVduTN z5KAKmZSi9c-ovd!qW2khJHngck6hV`=xS3s7qja3;myW^+TY`2JE41yRlh5g^NYL& zNVY*3`W6Q_Q_tIF&>$#F4T#*eX1Ia6V+22&XJj8qtxFI#%lnTi2SmoweO z947)OjY~4N;|mMUnoW^+#gS@6qU>z=u?R9g#EM69`BZh4!;~hZizi=>_a1KfC==CC zWTFTDLFRV={b%6FOn)s~lY@csrLz7!9G~?0>7Jg>4O!m&x+oOAcE%UYJz2aX0ZH!M2^SwYvWlLydp${cq5%Y<9U@C)dSaW>cUj17Nf~kYEzQQo!+qb#FRt-|e zZyDX@dNE7%)K|8Ny`6j&_019JwGQ-S)62&GRVdkU3XgE)t)@~XBEsx(XcxBf#-~gb zyYeT{B#F2Tl==|7Y%``q*tRoP8_jXR$%&jgacAAW-Q$HkS>k|IS<)Ff+b;Sbx5aF! zg=3;Jpjs4Mho$T?-DN96^Nv0+w2eMXu~>S;5d7Z_EWXSsUNGuLBXR4{rQA9~fOk$Ek8tRjDu@heCrDfw}bWfZM0yu#$VlpxR;yvud0DrPe7X z^$`m79AxvafSmpeoWgLCC)35!&>>=J)xphRT>nOj240Uj*?6IbuvI@G(AN=_Fzv=$;FGKR&_5BIpGhTr|`A7fYmOW1a z`Bq8$KfOEKDEj?>e;pgZ{7JYE@$Txy2e14@5Ziga0prs_MqmuRA(Oy3-0x#rBQC!s z!oS0aB?2&NRye(e%s&tU39!EtsFsJ&YbzGz(Jt>!d8QI5Dr0CyD#xu+Pv`H;7Kv>mu=ZaKKxl#WGS zeC1OokB+m*b9Yx`*0@YIY!3bt!9T6MH-}&1fe)5oN)YzI1>+ZP9@V-x)ki_xMHJc>stT-KmbWZ zK~$(@GkCEHuI&*isUWIu7UUHYWq@{|jm$kx{2~u^WF+qM2RH8mLqY(*5 zn6H%8#%1^YqUnj|OJiOZ>8Q$2;nvB6BHkWDQJ9JW7)mel5R)4=c~lzqQ(-F>D*~sU zsy#qSK9u%JO|v;!_R0TR(*G&iC&j)@xOXpLHkO)SwUF&Br+@#|pZjBluIj0;m-mh0 z8ne$!x^DpAVkDn>qrOGR6P1$$+c8aKF4DJ(o&sXF@oAqof2=vlh+&!MOhjQt$=Z?^ zR*&}d*w3=2G{ivu3E}c!2V@tBv7BT8d|^Azi{S;8+&L^zrQgD7r;R0k))pT9DfKZ1eLDVVL}Nv`x|}oLa8*3Nq-4vxn7s9Q z?}p#?0T1IQA0dx@S=`fhrt__q?2@nWA)*@R77h`yDn7t9EP%L11Bwkpf?Zlnxw}&A;%~7XWhiJzEhIZTT;xCC6=b6WCXl zj%Rh_sITPQuvLiNXZAIgqyNx{7&g*Y>xNNbhps_^PKe*BTYmcXEEuQU zl|+f@$sZJdC-CA)AV?es-czgg9g~fqmTkrck8;{TKK={ia?Wc@od=71>+5S>aNFab zZZW=Jd+0Oo3qSp{_h&ml_4W6@QGELN-Zy|4tEb*rPaoFL&in`ek)L?@qyNR9z|7-U zhdrtJf}yFX$>rGmy?=ZX=#PT&5imDwd^(4ZgS8NFF(`jabjFm_3c<)cv=pF*Gga>R zAnT)G&1ilgU^D-mFgKWbyGe|NbS3d(BT} z*`HNYJ8_x|r?ARCW#)+PeAKFUEm{bVHH&%!h1Ygsv{}!~M(G5>t>~n=6dtwf8@OYe zb&Pd%nfj9J-dKQwwc3K)%^U-&=ADr;TySHFEAnH|PH`(_BCC!f*S*~blLmHTC%J(V zT4!)5V;3cpUpZHQM5$}qz7*(=7glsg*F(FHD73M^wBgir#wkBkyoaAVMvPEb zZ7wM~AL|NP@ZzSF0t zzMkJViVqLJqBnp~y%E0CH{{#K|LyO;DqcEnZ1>#iG#=&3Svu_rGawjnL-=n#5y(pC|-KG=78`z6f0ocTh+ z`zo=!E zw`}XOAP#}@MODSG4KMI(U;c-`_@@HCH}zBZ2fvaxirfs;%J^x`vq@znH za0v8-`p9LJV>xn;_Q!QbTnGAx_C=>>G6qwPQx@9|-(tC^w6led?6w92-0{;3+EgI$ zG93$t{5*sPFX-)_xZ+fd4Fj`a`7F6wzJT36@QA59`4-t|IIg2Dk155PTDDSxrKWAl ziL+z7CLch0!d}-uV`*lc#c@Q9O1+}m%EUl9XSSqmrMa#t1p2a6#W~%ZK;j~sutdgU z|B%C?{M{CFJ04Vh*N`yEsa?brN#<^cxHyeo6oLGW1}*I|MO{g|+AFN-3(^ITE)8=$ zMhuC|xGaUWi4m4EMa!~#{%z!;1ok#!UJQ)W~+`MDXy#3x;wKYpx06ENs6VCQ1v zkC8pWxeVytCK|%F@X$&CAsan{lDYXqtoSPt9oRP1y?`JmzvwJGn|3Jd3E1ib)48Y< zQB)II_RaLD<;Z-Kt|0>H(YpG`Sd;fy?C{tJ%Bo8UM*sAY;{aqpo4-OWI&DN!CLV07 zlVpIm{DV8D9jE(K|ug|D>u-*DY49$VlrWMjz~=_5UkZPO-@Bn+D3@kB}SYmUi2?iO*tQZt~G zPN!-bJ%Go@sxA!Ss1Ztc>q;3FY3bX5CEm8d;{P{&JHQYaZ>Z(lsKzps?#VK{Zya!x zz^R3m!s>`q=cet?;vW5@f29|7~DUwu4GT|8ec$;uj?2s!x2iHJgk#SGX0*hC)ULdoXuqc40Bo4-F} zhhO36r|w_gr;Xw}*#JIuV19+)A8*|~{#5W!^+&-+_nL;aLVpdv7x2?hKk3Q21W#0Y zYaPQ$|5)e1!$wYAZUlP+h`5rm;iHYeh?I{8F||Fpv507*1J_Rm*QC$n=L;5PZS0Uc z-$l_28DqzeHfXV6Zlur?2l#Q})W;CVZns|jZ~dtP7qQRkdpif3tM)Upj-wuY)Qo1n zm(mr(I);b?F3xH-uLc)dnHWMcPZ+kPLy|g(jJmwcebf0EgZz%t!oX+;KHK7ERi7$h zTvM;0D^}aeXIUC(H*?P(i@CfvRw;Yy;00XwqXIN(U=?o*wqtGTSVnX%Vn>+`R z%>mJyK$@-g@9%&7zwmwbd)Y?dsr#XKu~GcCHh@p< z$kzM2^7shUBn#;u`kjB|=LDeA8H1HOAlf~4aprCN`n3Afa5PnSi3;4zRU55VfqKE*e(G4T9( zJ&p}Gg3Q39H-wP&UF~cT$;JB3Im*%EFZ-t>{n-wx$HKV0Z+JbqQIsxkN71MwfcncC ze8$uLip?K?o5lb9SN`-d>|KA05qRqU>f6~UUTy%Nn)ogHa;H9d1kST@{etK>2hqpD zU&qJ62*b&n;!PbVI~IXHU=_zpm_GvEH-Y>mq{Q_EkTSm0p&vHEBIHdV78br>aDm|q zO>OGdCU9YTVacYCiwPGP>iFjuDYRFM$p;Vr$-nVWKKv8^+JB^L2R*YTjoUAf)}Ga&W^IDU03G4b&k#J=WHwP!qT^kXo3!S zt1TqJYCD2&@zG%4GQh#=O&A2v9JkYrB!l`MgEx`5bJ!S{+}w|FZL?yR&c;+(?Mj|B ze{mQ@?Tpi?-xjBwIn=1YV zkiU#Kqf<{DG&CO!`+zDr`~eMJWEMaRzl0}wDGBROBM{)0dL)ra4cwhc0;f zr$6(?r+}RBeGDC|$fx)P)3m+7lfZ2LQgZ{RFBxN^^RI;HDIUKY2wj^%*4hYSaiF{% zY)i1n=4mkjSFNQF+m2!2yINt}XjpLs+c{#_g^|5n@$8a_sfor`l+RdU*wk)DW@av) zmfg-vnIO`)4FdaKuv!v%)t1<5N-Rg2SZuMg-bw;);0>j?iOGB_19xw8-prG#hOv(w z!&LW~gNS?JSOQnN9-p_>_mOoL#Ymuvd{9O76-C=%JhdMUoKMQMpSoGB<#}K85k{H7QXbdU`mIc_1Mac2WUI4XrFTt%K0j22XHPf~- zpHaK*_{tk;w|B@bCsFg_RlI|0w7|V8)6s#wTqbJLdEFtb!(UzD+VwT;#(tLH&(0W^ z|Ed@vy-IXIjJZfdVj_O8&`M~G=yjDp74iYrd$yA& zZW=*f2k$ksgLZzj%#EMyJfUc9L&Ak+ZGZ9b@%MZ$Hh+KC#QXWZ8G)<4-sKa(_ol_) zPDAQO{dPjUPrkq7ANxD-Q7|`w;|s-TASbDoki&~+e=!8o73 z#K9i~!IuHG>BA=PL&(I|_XD|bcyZtck3Sf~7ry-U5xvudzMcp|=0CO%MsEr^vcz!a ziCWbJ-5;(?+)P|uNHVT)g*u(cWTe}LSt{>s(i<(zupb;dE}j#2uJ9Pt@{Ui8W11It z;w<`@JYxl&xW-LfYtl(ZiC?s*_V!JFu_u=^R~9I_tP9`AI(X`|mGOe5cGSrz{Phv%i@Q%csnN_;I?7RXYuu0x=xbxiVFpUVOF8}C&d z_}KS9{<*I|{I*~Jf5ddJ-}4c8{taO6yBh=bOTYY;&GPZbUugX=|COJA_|lh_9>ZVw z3;%b-t^VHc`QpRR{n@|N*!O+;OD+Gq&6Ug$1|I_~oGm7;Bp9Fpj3(zO8 zU+QlNe?s}A4NRznE%qsf7S|zALDWofZi8Dt0t(VC*h!{4c3$YR65WOx17NSvU&BQxp3v z<=X!my^2pJF9Tm#B~#bG(Q4U=E^PYDxQ?-NY45(mk-0QxIFrxf$V(SSV+;_(rEM>; zFlcdyi=6;WU)d|Rt9f?hGD~S0!bsCikevg9`{P&wC&t^>$9U9_G0ZU3L2yImBb#U$9l{1sXmwG5F7@9HZgpnm3mmV;XFyj*RO;rF0YV&00f?| zu`V+$F}(?g{E9op+WdWhh7o7WxIyI8K^Dq*FRxTgw}t|daBorPo$n`sX(4O$ik}AI zcm4hBFa9}f{uu3EzFS7%gMan+{_7XF*G2Q6#=3BP9Qv#O&(Gf^a3lAXudIz1iP?z# z;xGR7n||Nzh9CJG{pE>Y`sJ^xD}VEE`Qh%;5C5=d*njskKdV{#DZUh($*O6&PUYhZ z_kAC~eE2es@A<-q4`0M4kjKX#y?FQnHhy1t`QgJys6S$jkA7eBCEKJ3f}|r9R)plRB~qLq zGw9*TPkyi;93j8h|G;k!iZU%dIUI7>3eU16TfvZE$TovwlLSEk-Hm29&<*rZU2~P! zT6?d|oq5jvhN`d7HJq%vC-+`^?Y%SeoSP@Vywgg}_kpBMe;J}pQB%;Akvf_P|7f!7 zuoGwbh5!8T?#^j~lWTA_70H}>*ocHz*y?c~UOBTjCsB@G_#Qn1UPZ^pZt4SLR4~rD zXE<)C{h&|Ci8np+bAsoPkb|n#l5ybZHC#G5ff3guams_O;NsD5fx!tS@G)-3BTux( zvVG(p?U0x;m=GnaZbzbunZx{+ou}!rjXoKp;EfAGf1I!;m_>5q_cqY%+>+C4#4-qM ztGMF!Y|d5VN$wOz8XDM4NYLa=+jACMlA`L$!gpVuXdI3uX67rVg4E`YSw<~f)-VU_ zw)LA&x5HzdlA+Rg5XX~f4V9SOS_m(OxX6n|A9vs=b5VAk4=l3Y!{hH?ys~@o2mTx) zV(iq|Y_S`DP-d3B;59CjNrGiTd=JsJSCJF9117@UKr!3jFc$ zx&yda1hUoO8?O^2U|M6sgu-vq=U=N__^_}yqDad_0gG4}N0ei%Odd6(2iNq$JwO{l zlA-g{%)EV4*I(Wfw&r7B)-0@jrd<=a>@g7^2acnC&13BnSBt%jmf8smBzD0azv5x8 z#|Ti|T3=|XxKjzkAlCQPH5-iX8}r(Ky=E^l@-UW-Ifnum!R*@%gsOOM9EJKCHmhLQ3qG%Arm0}%TAR=IAg}x3(LI1 zWDk_b&KnKFJ5ACWX3DCqgtStO!JM8aHSe)h}2{*+rn=cnj)3we=rgWg9iWUWMTzaED7K=dm;ALStkq??`wnqxj=ltloU z*}VAs%Ehn2dn@^Of zedvj2aw1`@Y0^m5=rv3j-@15p_td#FSp50Hh2Tvr1iAZ%MId$q{Zeo&0`bxU7K7Xo z)B}q@E(l{0h&hCdK}-^-8wMFSAnpkA-V}?!S*FgZ1SKaG)pQ`FI%F=W=EDAjHH4~H z*?w!7ZM|+06QbAg3%z0zjk95$^DQ$-SIt`pr|P~l*lm>u0$4HC2ktnuRGI!N5IBVN z@<@?qHF##rBDa2kt*;mwTw@R%B2ZCIR-q&$|D(tvW?U`~SDN51v<$ysn8_=g-jj?~s@BGWQQU{`w`Kb?jy3&Jk=Sa* z@hYWh5<=@@^o@-pMvd-zST7hCQha<1pZ-I@han@TVn}5qKTCUfu!~_wN(Z^ zv4f>o7j9r4MO^k9GcO)7uL{Eg?*{n&A2?j}p?UliF#o!ad6=)@z?U%)OYdhyGA{nq zpmuH%2`~F{=kKfUeXX~1lkUqAIP()f^^-9zXv$o~SV?$OMs-)xL5CFHi8V$D4c)A!aN-;r*T# zJ^ASm8!_XqX=kxt@Rcwo4v3W-?^BE%<~EEkFC!k`C>=hi)t;#T(vN;-_uu`Kue9$2 z^gP76%50DIS*FfX?Sah4nGt$D&K~=8`f%BPym$q~aPEOd(XV%RRoX*WZhSvg%5Yd3r&V-3O2Bj`TOB<;jPY!{^T?>*=7?UDjabyej zU~72faXaidaGev4Gw%G(!Meqeg$vAZRuMSF-xnVO~G3oJ)^iLb?oxyJEXH@~H+(wZFnn4bu)y5*eK>>}k1+8k;O z&S#t>Jrj9f!WnNrwX2Zx}1RrA`4>x*^#VGo^u^z&7rM;_Vzy_at6Em7*D-l`649hmb_6-GaMsa;L>Yw~O-6BWGxfW`{HxkuNf7KXALqz1IM{c|h!!3v zOK|!3zy6wzE@}KVSwm-*BR@dorUCgp=9Hbf@#t4@iVT25YMc?E1~A}c$UkcF9N#!ySa=g+VVkQh&9Vl^$JVs;LyYw%$R4pHk8`L+#YZ|a$sS7cwP4t_am5Fi zmuG`3FXTk3A=%2DV_40+7WC^kIZACzgex-+iKb)sM^wbD?P|6N+}f&0j7fomiQ=)+ z37`&RWJ)_@i-|)DEk2!s7I7Le96B!pBz>esfCaa^IBgJSnm`B%LQ{z8A*M-4O`64E(e~Le_KiuMW-w3D#3P%3}OU58;9K#X`9Q>`0cwk^2 z#>+GzQ_qvD_mT3v8pB*1e8%DV#=MOq^N7Na1WRL9Sn+;8F8==L`m4Jic`i+;7KQY! z+&3d|A`3wF_NgbI&`ZF6?;k&{`pPTc)IjhnR#;fq4>1Vb4ktPSZ(MvEi@>Mx+{Iso zd56#x!=1hmB43d&Ub(q@7k{yG5f}@>vseh8)?zRgfmjT35ts|YSOju0*cX2Klo=O+ ze5sg=Lgd!_0$JnXPX$Y+j)_8xLQNQ<6V$&liD*JXi(^vqBuGL`ogo=Nd?*>Ogg?~b zxcnMWZO1`vhk-ihX}4KA4qOQErc6B^vmUtp>Y|8zmZ;;_ZcKbC*-b`N4ypGIu8`kR>(|PP#%QKi`xn``8 z(?5edchA-1b2vtSj8S%Oz@Z$r2yCNU9hDUM=+9)W6TNYft{dduVz900OBX288@KA-Bh0O6kLD?l7>(4HHi~-iC7ui#N@;n4gL1 zC12Vo*9ZR~$3HLpZi2{Hg`+J@1S_yJ_>}Me^z!xHk3K(sIC0rdAkoyNX>RwDu_5on z2%P#){;;Oe}3j zkc+xp5V9?GQOMkyf>`rTPA>aS*(Dmh`;Y(57kB5-Zd$@xWn;ajdx_#`tv<-sNGW8i zX81_Dz77V-`q#qnD;Bt$8JKG6z=2W6aA{JOJYdl$PS_kG+EI;lj-hR|i-Uv4+B$1t zVGmyPbcvIjw)78v!~itJrj5mMB#S{F4N*59G1?OkExEGr5c$__=8hcav5#`3;@sVq zF-o4n)*O~ODzE0Kc(_LgTPP4D=b_^AEkMr-pMpmY0EtZ|a7UzRjo%Q4D(#W#u(k-C zLKaT=_}JQ9QWVj&h&gDZvd^co#L-+qv=}cud6E$&vkru>NboHy{3Ncv z{sq7_$XD_B&G)YCe)H4uovrO;0cQ};yIvdujo_^J7I=v`6B1--{Not#5A{be!90RL z###GoVfa-sl+P55bM{&AJcq}8&aZWeuXcIlkw;SG_SrhH(>bXZB8OT5I{h!{w_RV;C(uQZBeQNhRzw>GMNQ_@Sk!Cr88uhzk>2-*TL z`q{0LQtfE^O+ctPqJxV*%Glf9G7mAj(%WKY#HChaO9xr!B2Q9_LV-mmLxNPdy??TkAfOo}hcaN!la`&;5%RoI4y+9?j?nIuoPt`sSO6fp>mn08e3 z)<3k3=Ytl`QJ3((>MysSf1@E=L>^h<(}y0^`9EqYrAbvJo3te-d}V_5Dt{>oAJ*ev zncNA~Uz3v24z`8o4)e8l0y*dC!@#&cG|IY8I3J(hT{`#Xu73-!Gq4RV2adK$`|Guk zWZz-f{RubAJOU^767U?j>i)G0zdTIahx27&^Qiyd<@@f8Z}pvw0HzkqN-ux&6@8cA zQ;(ik%@1H`*5WfQ{v$~fa*nyRd;8LLJg)4Xc+_7YjjxR1A`lBg?gV;g&^v+t3zNHl z`s)%CjTe7h*ww`#b^gc^hzl!A(BfSs&~hnVPI&Ade+*)GL?>z%j(~47Sj?|7Q&5 zQK67nBB(}iFvG-8w$jNsHI{L)7o28;JrFXZPb0tD8>9I&-zG55VI|3^ICgBW-Bt6# z<3MGxO=>C?lUxD!gATx*BUR*^z@X(gm_Y|iUHx>@heafd099Xf__;6 zH71+-QgB@Wa$(5XT+2YnS^%O}TX68-{Mz}$;U*^38=R`d?Ni=C^8#ZXWOq;RcFlHp zvB&mMKiY%mAjX8}l8XuYv9y}PjDyVaG>3fWwal7DoQz?lFXIAf$HJ?zX=i+wLThcX zz1bkFE{?Ll<_|F^pTPztV#k=dzg`eGz47cl){24gtKWeaaUGxho}+r3H`qH^sPISU zaf-$g$rB4>gP=yJ5^z-nSO*r-8jS+m&gW2O2?Xa)HfYM@XJ2RW`jeJW#I#ccqgRXL z_=-1PA1$XjrH;4-n8d^E107_ZslBC{3M(<2*l!i%s43DTYRC}y{N`NsCf@%S1s&JL zM?0A)sAKU5y~trtOi_bg#Y7lH;t;bNC0*fLeYma12j*aX^^A4}x$}p!gvB8KG}njg zZuK|)?aqAO=dN}d; z*X7$cu3=Y@>M867p3Vi~S-mvO4~gi*BA9gAr-pMkklz&;FAIAS7j5y*Uth4~f(IEm zaq;W4uHo?)e&D&?|NPr8BQ6)OD>pDUX{E3`@Ao{xHt{>CyF0HM$Js*@2t zFqayy0FHvKwT4+ZYnI?c04Z{sEptW>nMYb%V9Kp@zi3f2h`+WAXr5w%UiH0lrg1jc z%Ep02Cycc^;B1i5b(M|vVuj~~?U+0OfHN(W7%kHe9`aoV04I7_O=$OmaUi? z<`AfzV$DX{s@ykf7Fubr&4UtFJFfh3%#3M0CEQqf2ixF zVf@}ZUm8Ar`Vq~gryo7H`{47Oi}0MvO@R!E$}s~`Kgu;z&S)d_bJnFE?=Z=IIfz%{ zuF;G6peUOyM&Ny20Ir>cqbv$%zYF=5GxLW_$h$W`V6t}Q&bT)(w@_xdUPddaEXAHMv1yVH+;V0Vr` zKU5|m`Z=0q*Db~SEU-NsUbeG7Djs&+P{1IEw)sJTH)i52af{%@4W;1nCx~%!Eb^;t zowegpjuMMYYePNdsy4=tR)rKtAkmaY%@9f)3ym-ZUXRhX76&xS?0596g=_&F&pGzh zEwTxU>-bUE=!I5Xr*?qk{Pb<@T;Prm1{{1jw;bfdfAI-nf-D-4P+AM*5N02hzcgs3 zaQ=wgF~Wl#iu96;aa+Dng5!h{Mp!Uyh`PmRo3b>_K&h8DBE4Aa(YkGrM4Z_ioh(g= zK95Im(yj^zdQ#?dRy7WkFJi&>+n5hu|Gj^-`}iOK>D`U%*YW+h*Rc@fAKWByQOMUZ z&|*F}F%DcLMq#A>pcMT=E-Eajl{tYn|631&h;-rnNoVf&=?`t>Jk2>43&PL-aePyR zN@i(E5>{I*N3CY)pyrQ8YV40u?0&93M{U3Dx@B08Fq_rA=;OM>!;Ri^3&6G0v9>6D z>>S>!fO78qqs`^Qg}@pB;j3T$qaA;RWG;7S2XVUkQjMzE@MLS+;)xe6jVC&vzhrU} z;N-y?I{m4$W+dsou9*GBVd%3`IGx?jRV?K0-GQ~1d_e$_lDfYYb1>t$fQKM)H)zJyCZ6*d_H7k>PSx~rHxJyG#Z z$nRZyQ!zMMe&7R7?%sSGZNz(2)PxM-Pz`KA5{_NX0cy5m2Wt+Df}w8;iIp zSrkW7$UJ(grHw4sES!$**Kinl3hYNorQuDDK#?{{tU$y>A2A9{3loEH7pg3y?$(as z(^Y)QIB=a%eJR^UgMHTPX=7(i^`ZaZ{}d5)ycSlOH6{=V6qGf7V*rnW)tz1T!cv} z%LGEgSl7JNSsZfB7L3lJzThcK2Lqvvo>!nLKvAs2@5j|V2u zCKF4&a1_*yM|_p~giL?FZH6!C;>|etdaiT3Klj7@fF_^2r+3$`T!f7G17g5p%q;W~ z)Ro`VWI0cJUeGUXEf{pR%Up=P1h?x&VyLEesX@0RXnZ_K8DOZA2d45CXSctv#HZS(Ec-OF^ufP87s!Cq_bn??rUpVyli}4&pnJzr2F`dvv zNr3XqT>ICFK`$%N;Kg5?6#4|xY>c10I-#fqX&dlJsJ6WF%9pd{(Y_a6_#wrjk?nEO zQ`8fIYECYE7DvqqggXDmy^8mv#QTHjPfyp31{>a&`0Pi5PyK}#K7r~}yKjB#J0z(5 zU%&a4c&U12ZfMb(V6QP#M^92>QkIp536Hbx-9576!E=Q1fkK^8$mEhDWMhG2ba=|z zdV!qqi#Sw>u?QETFHjmT1`o8c#8Ia4KCsYM`bDve~kCvY2<6tsocGhC{z%2TwFC}XhaF>s&M+ibuhu7LDM ztmXsA<2fV*F1Cqo98u@VyNQ}Z@g9J z^M3-b0}Cg)j2zm=Ut8k7B~f1AD&lsYu6v~dmT_XnfX+6^j|851ToH67%wS2O$P!o* z0$LssK| z{E~)BAC_0X5nrbgS7$u=Uwdu2SbX{AZ}tnAF>s+h{q*DbedKrb#BkiW{xUwQh!|E+ z6k2G3sL6p*CFdm4KELIa%-t&;KvH z@Fxl!laF`tNEl^O!|#XwQuX3ZWLrLaQI7WfO~6 zjgWz|>XGF+8l_@xod{sr{J>GVCp$(S(FM zOR*(i`2=@hUmixRw(dK}?XgxlrWaZl*Cabc-55=n@R8oMMxUR0jbY&cLYkuLialcP zi)XL@28Iq<`dx{}_J#|!Se*C#-cdr2Rgo9^)lfUzwcO~PX`ct3!@GdZNV!{Z&o4Yr@^_DPlIk^yj z13PJILi(2mLA%AgJ@?#)LCA@Pj+T={h=mOPU^ypXE|})hn%RVzvDA-F<%qOiWS}-m(xZnp zBIRDFaSG?c%QAg|kc<&|qVa!-1Cn;n^BlHurZb5w&8K)SY(b?{`yf>YD_$b0 zjSMb(kQSUs+uHy=n@>q`co_XMVZ}RDK2|$Za62sRMjB;ZD6}r7gvyv|>lg^?dI&2z zP%a3$`*#t$e_zB~Sh(elX0{i#1D`_-@iz049r z{?1!`)hcrF)vgy__$2Nl{Ra~+T;SW97@wt&)VTPwJy`uX;Zm6kaTp3ZpH}*x}3yMf9hkP_*oEae$U}&Uif%(6&+84 z@4WSnsLwp}WZELo^Us&R)bh!~?9V^{QC{uhdJ^GFp_XZ*^`jqsb{8*YLnmMOt=((b z$)sNSpZole?p}ZWjcBsGqbc9Of;BQxXN=wM&;Bbvyu0{rpKNIHUuEwm{=}1y?>_aZ z=BoCY$-nl}x0mFBG5! zaAD@9LNW?DkqOdhlBJqE5LD{+rz^G4VQ)D@XrUpvFZ8HZ`f$7&x7mrUo$Vkvp8ZL>wJZC{KQC}UKgXeo{( zH~=z%z?w&L`3Gn`QU?BQ?9^R??WcbxJ^{>h`oo`|`p*OXZ|z?F#_PM!eD)KOmjOZW zyecw~r=G^<0!;6`dvW)@-;1}ypge(}TP4PWDvG6i>^a|0Y&~#PiYG;I8y(MA$vhMp zU{G+B%z6rg)Nx78&r7yR%gS(oI=D8a3E9=?OWYF}yt$Xe<*L%WX;Y77(d;7fumr4Hch!#UfG#6T>;|JgQAF zUcPb#Bs*~;39rkS1AqEy?DVx~2R@xkoJjM*cT~N}_tV|N&gC}+-7_EX zC&g%seqLca#;5M?{Q2dFZ)Of%Jbm~^B3)r4wilUVwwsL{)j0eA`hWEgcifYuSATHA zi9()a9E{OY<)pT%+q&el(nw8+_-(usub!s*j8&Un1_psI1#>r$i&;I;XX~nE56n8_ z@cS64^TA$Qrfe*}-*^4(86*cm~a=LmpWB$v2QjuuP9Jf2Nrg z=T6uBqYbaKXyL~lKYry4U;5?Gzgb*8oLdU4!Q)t?Kk1a!kexS2 z-hga#J&>9;(G^l=($i^i`PnUV)z~dm5#kI7i%(C-3rabrPgW%?5Ddn0JV2odl|La& zr{>>mL|NRc&^j@7F*xRoKqbe~CxH+iMUC+xDljZFN1-Oamcn=mn%R3h7@(AHFW^4W zQqPb`J6yhR>H<3&tp7t2M`!{0<)U&UIQRy#1+7# zR!6ouXEUz~3!epxozIvM`}3v_O3AtKzZZZLZfgz4?yYqHp(u-8|A&wH52A4=xeoV9wH7(kzVyAx{q+cuKwS z^UJ{2f>g zq$p@vHZJC|bWUqCDpGQ51IY6nZJ!nbLJL2(Th!T)pj@Ke4p-|o*Jhi$Er7U&7Dmt# zYt_V&I$_PL;6?1YN2yVoFxpo&^xJb-O7;ay|2DWEy8j1-Wj8Dv=TgaE zFie|q*5=Q$b=LN&7-bozahvfq-?*Oo6=aS>QLi#%x}6o*a|3|+u4@~N4Yif#DUab` zitE9c%9`ykY_O-&F^)`*3&6V88#plRduqi(k8F-hA7kl&HGXZ(YE;<5O{8v#NTH`A zp%#Nml+gzm0QE>o%^DZ(Mp-ZX)9Cfk5T-h&FQIh~BbTCf6MclzcPv3}^C@VacoFtb@ZB4!!4j|5rL6RfV9Iu3# za@n`m5rysaKO&6*fo-8~rZpj9NNW%cMZE6cbg;bx$lbq-cthY{{H4!9s&g9D1IpSQ z+5Iz4nE?BoJ&@KGAriTa4RxBUUu7FNCVKiXPV^+K)mA8tGh*3(#343#N-=5!g>Ds8DOT-=IE|%wGNLZ1Y?3@l}?!hHoFpG zoX}II4$z+fz7Cm>{{bJ`_?O&dIauj!+Al>0+I?R4abp2}NbG&)g2%n7=iQgDLv?R{VufLHOR+!&fTp3@t2 zb5K^sJe$-I+Lj% zav|XfID4%%3h$mk=CF^)Z z`>gXOjqWMve;Al&KoGT`@twv1~pnCn; z54AftF}mMMlZ&zDY_bF%SRz5Q5rX3Df$E}dv;+0|3Wg(5V~JTSUP4BWGTvd2VE($%t=*6Q;^%;d zgP(t?X2gT4+LKJWhkn=sad1Q9k*wMr&tu}Hmirn};uzj&1B~+xv)Pyvj~FpWqdvBm5sj&~LIaA`u;Ey4 zX10mfOdV|!gt{1Wn?}E$tL60tPzSiQiH(c6EmLbRW5|xGH+eogpukn(Ge?i42s#9Y zBL%Rfue2$wa%qR~ZE6$LHE2_qW~ZQZhj7di*A69$dYrh;#;Cg;#Q6M+2Y%rLy1oJi zgnxjqe)()`O$mNb;|pzcJ&KcC${>|rahv*ct)@IFxn^g~SwDUrM_^wdTU`X&Yv{}) zP&#=nRSi(bw&FFy{VRW07Jy!?t$$x!3|?Kp&5O0ihVM99AMD@z{g(#AKVDa<-KP)~0+@Lm^w_ZD5}mK>%t(#(nIalDOYRre#w%n=XXYT_m; z9T#T%VrX&z@}_P+1?%p6vt>*+fEbq@7 ztg@nMG3im<$aPF{aPVRzMj@=}qR^upZKlEaT+?FKV1wc&n$heK+;_Ln85*awT@7;^ za*XX~zBs8kAF*bK;PwRWRs)BvkIOh_I%9CGzKgg3R=+BSM3&ZwPy?V`#-AJ(KnptH zjs>y(!iX&LOkY1^6B~RxW~rn#w5ZJq!>9>cKmq8OHdAf2aUjw!{#{xHCf*tnOc9;njKXR}+$ckR$u9%Mm9n_w+_-oi9iHb`R4{_RO zo+HfqKEL}DKanqj=I-6T9kAg`hI5R*{&Fme9@o#=tPk9U3*(my|KV5vs1cvw;_i_0 z;)^epDJXuEcjD*H$^^O$45MY{WNx6g9%$R)*W#ovSw5%H>uiJVC8oGMAgbV1Tl$xLa*?6Pv-(O7LDCzX$|*c(F;NM>eW}@*nRf1@zZT@yqUjA=|HGI z@$nDs-hP|EKiSK-za6~iKKeZ!oTTEGfsg!L)2NK=53CVB1f}P2w0VILYC=6 z#fv8CiP^VdMX3Ht0l>czOj@Q@o^gk;?1GWUKpPPRNIm>=Tl+jP_c8Qog@_$|@G=^uR#V}CyKz38- z(g~&SC?=qum%Pjcp3a?5ApMM?b~zBEQ3XPtTdE~wn3cbgM|+2pyH;s=7ly(Q5w^r^ z()<3vrWbAg&aZ|=kAtH?8 zH2p3JZ5I#?vCKXkbr;Zmo_7I_M~(4-FzyUuJQ^0`B|9)`;TK~@9`*hR`zk(hIb_Wb z4wP9MmWp*+x(p2p^`sRUyfm!Z1&;X~gF(7aNL>eOuq(AdaaL^^uk^3wif|F6`6^Ot zgSY{SSFH_((Bv93Jv>K97{`B6z_|xtm7J2;O2VeHR4uFe3?*8#--~Krke^30}kFPiAeMNikq8V$WGjrTv za%bBb)>&)O9gX!IO|}`#Acw^oulky0lka2%7CV4zi@U=T^xdXla))j%-obn49qzu3#gY`G3+i-_2-d}DmMoWzfR>_htP%e9yN*`SYnc>SepzBh4xzv3eC zA;4Aa0-m`QF9VaxvsQ*+&}WPk$HZE(0B9<1r&=MWmMI2Qb}aj+=8|f|%zG zst8w)aD(dpFi2PxPGjQ!JCE%guh|w}Q;;EIkKf{e?%48&!R;}p$3+j3)O_1r_r3(u za5f1F+so3elrtVc=j|NP!=o5$Tr=FwxJukY=SmiHK$LZ83+=Q-scjMhZu2t<^%_R= zK{)Nbb=1~NZ;kB*S%?XsvZ>V=c*=TyL83e^1p6ZJZ{y|PliB@qn@_4f%7`7rS+mWU zW|zGU_IT3Ep2p-v&_EK*+Z%K<+_e!n^&k94|DjKZlB~U#Z(iUXwKaTM_x&qFw{E`~ z2(uQ+7j3t7>GqQy=DEz;J!~T~x24;<)IBBbHFVz7PxbdizBRpE{Qcke$xfUkzaR0E ze&~EOKKIe}_db$!+(qH9<11le5g3yXcf(kC^g}@EmTSHIGav^BRZ2nMKg36~*Q6U;iV&pvbM!M@vvT1T$hV%u-_35N4Ij_XJj?KC#jnZS&ikBzq8ea!6b>9)d0 zgY4_N@_OvfYxZ;){fY*$Xp=k|_wj0e=4GkerWde?;sU7$cw^z_r~R#<;k$lUHNEzYDzDk~R4#7uL}(*40P}YWGCx2^=k6Z*2sr@p}YI zOAe<}TN$hRQ=k7vYxX9Pb^J0f`f7w1fA^)TO8&$r;=P0a{Kc;>Y~TB-=Y+lbM{hLS zdzWJ8m^+Cqm$5UrR`$Kbd<8e$$8uoURf@ICRqt{-h#7zRGiUefOyYO5a2Jq^EN0}x zyMnqgp+Uw&k3Ae?G;;H&>5P#WMB%N5Vf9nNB%rYGjN`J{3A6#VZ3Su~^LrrscxG(a zViDMLnd975KS(ZV)NXQ@Sd3S-nbYgo)9O0f2&HVQW#5*uTBmDn4nTI&G0CfP`1N?r zo}|hhSk*Aa6pwZeKK@r~m1&5+i7ftLcU+sPa{y7Yk{sq z#|+epWnJtX*iDH#0Ls#(`jfw`{Q(ev2xO;E{{ENv>X&ujU3 zd!fA#cN&gW)S+nWCa zig8i+&O4X(_U^Nv`Pg3DvmbnVugxzn|N5`}FW3dd&|`c##Qo8CSz_qpgoL~dJL1q1 z7;*Udye<}r-% z>s;fk(*`5${Q*2Nzy%MPK=UO5rTJ25dMInQ1qY;bUDjyKew)&zz4IKOhNW*JE4Jnb z!JkGRFe)s?mx^q;*R?K(JN6`IoLAC}G3Rk{`kX}Zt{-=vym(^!PUWs0+ocB% z++1O=xNk*_#diAG=3T7HA+he}(8eg&A(|!x-Dfc5#cX z=4`&Od4UlOwkYGKP3}UYuUb94W~J0da2_3 zIK2pL`Cwt&n9#md>PU*44)zO|&O+!mPPEk$J~$#l&k>9okBD?0SjsqLE5jbvc2w@< zvzlWXD-?<cu~04ofm)W8(J`N z(ZZYT_|R=PZoYDkYc9|++RU`m8h7-(GmSfQXOWEW1Ekz`0Tk=cp$vFE>W@k{n#;t_I?48Rc9LjVX2`oUHSaYEl%RYbIOVnKO@zcLt^m+I1 zts6IY@BUl=GRBY3jIC@V@L-IGJ9lq)&rdyP+UjitZhr*U-gC%kTo_(CeP(z5 z#&u}Cb&S^?wY3|{8ot01Sm20xl7+i~{0a*Gss?Y^%*O|JCAf%S`^gaqg|{SgcvItU z5AA_tcLN@$6So`rER03jb;V}cI&1q=KDb15#%HO1!T_AacLPS2JfiYKCy>%d*^EY; zhT~+isv3uRC(V7*_`&YHj%k1CsZ^}V5PEE)EFLw8dm!)3Dvl1luL_Z_5sAB7<03F! zOl{YK02q6lf->?$YZ!$r4V$`9R8!Zw)@&3?*a06DUiedK2y9IThcb6%Swf~B&#!py z^$&q~_wP5b`}ddY`H=T^*hb*~9)ZQ5Gxeq3qjgPBAy5yij}~oXZ6k0eBfy2>xwBWx z)fl%(U!A^AvqnNin0 z8l(ifty_ERJ60kGIQWM$#HvbjCYaGfZ2fDLCV)3xymQDwhoU|eu!zdraD zwA99mk0I8_DjxK7avZ6!Ii7GF=EWcX*y8hw_W1Azb^sw?2cS;?BgS9;!cXdtFU8yr z+Xy@$BXD;Ajvt>6TiHh7{u}{*BKRC8?s><-S2c0UvwiL@Zy&MeHe2ql&bDb6Ftf9r zdZ14?a~Du8Xq{-nup`JD8-6WWy@0ENu;PP9k5K3WXRS_XD{~n+G z{c(=zR<;p%z(xQ&fO;yRTXX$@^>pjbLp1{ai(V6axw<_FyxLh@hqlxMpIK&uzu(kJ zw;?fX?Dp}-WI1ZvSnGj9$Z>-kv2N3tB-i7^n_l>uw_dXgokuD4jMr_z*D!d8mt0G% z&?8=NOX!iqj*%mxgbY>&&8o$fzrjv3J?q{kQ(K)vR4Y6ahuBNdi0f!q)rhoibeR-9 zIaL0Q&jn-~RP`0DlMX|J%wo0uSN{EM5YB5Iec`>ERoJ#~!`0 zyOui}!^DrFpkB+YR~zN9?`QEQvIsf{xec9ReN&1$^}Lx8b85CBm z&5^duG4L605i>Bj&?~Cx*ki;Ex3ap;?!7GzBTjJ&Mp&z@P|%Xl++MP6cO5{}oUYet zipmj7oE2BIR!l9w+QNpRRNkr#u;Y#{*P;2HGxC+A7>lSNgAOGYPDJA|*b|kb5Asi z^v>Og1;@8QwGs!`y3wJgZS6gRwzIABOD<)Wj#-Zz=+d4z4E6~R`vG6EnM<3Cv8JaV zb@#E_nw&zkL}ayva0wss5HlRjQEf%0u2^z=t=9-^wt<&~iXASuUiB8G)4Fe(2wO{|1o!H;UvHGXB?J@wOr&l zmpLMvpNiv+oE~c}hA<4QZL2lFj$3jIsx7c!W)iBs z%H}%`_1;=rvxZ~!?Q%#uCfIG^yQ&jZ;$=pemg$H5Hm`AIgMs1wQ0t{Iuf4N&5Y=6o zua$6*_L|x_0KyXp1n#7B=T9H}KpO7;T?N1oe|+k%{rRZ@o4Sp_gEa!%0`S2)pIdKM zMnJy{!jBW`BZcFq`FJy^cbd?9kXawuop-k-wnK|R2wva`!-4go4-n?X-P%jR2gP9@ zM|-)++9*!P3D?s7;|5IKrQ3kW(BqgVjup{@!x+_8D8NJG4d3``4Z-AT>3Uij6T zw=?T*Q@_#mzLL}Ikh(nKFf44jFse97gFeI`ZPA{<>KMqs#96c1%el;P%+RAh)z;(` z8YA-&W=s~>Yg%A=Okz|mV)JL4<}z>x|1qyjQ-B(7kc$#xJ@&R1T5Drid)sNKJlYS` z#h)3oPq&W}wRo@v(F$YHvU(gDqsPe0{FyA`0xP<&7iGhNHJ=muxi=_&={G+4yW4%} z=YK-){Tu&WtXVgC8-e?O1hxg>{eKR(9vo!^9>M#0@=L`y`PVY*)n<7gXMryON4WFa z3q9>#Ae?pI+^nlcI~Fe+17q{}AV)F|O%{gRCotK}Ij|;y91_~^G~;$A%ZraW?I-=c%K z;I3gq&*;%hzbV&Pal=+{P-_l**0y#mxYX@aZL{1tnJ)=$;ZQ>{Yrr!X6L#EE!4m40 z-Wp*zR9lsUiR=X|;?ggTQPrb!B3B7eL=oIiDwQW)jDDc+!rx%2b)Kzvclpl##oX+6 zbxn@tRyU#BC=upCb?QNlF_nx4df^v4fcW4CxZM4_6poCtm2Ctb_z~C^fDimB-a6gA~43`?NYu? z@3`9@yx>}9FPbdhxbYHW8GT1vFyiPKWLUP{x?<*Gz?g>**&Hw8P^-2=5iUDGLGJFP(WXMk~2%eA!@FJMXlq6laSZy%@oWnxi?z0m|42QrXn|Cx7EBU-*50T>M?x z7Jo73+wq|pfo%c!(46Y6qaFe60O|uF+zI4r&^Hn@#yYGWoAqv~5?TErpQAa}AYGn( z_bmc>(_1bIdDEM4=nUz{>f&$W=tjE0dJHz!@eZt4VziD&IZjDxY&n$A-(q8f*;ht&0+aKIEV5mdI-G!?tOoj^v*K9<0Wo55?2VEY)B#-)JA zJQN9+=+PJX4QME9{F!FXT3w@eJ0$)mfD$1I~PX5!hee6^6d3 zPuZ7IUfTGQvN$-fj>X`J^%zV4?mOBPto5I)D4m~U4>;n7zx@Im$C?b|jAofNi}lcE z-#|O3=d1qqb?pV1ImTNJgKZhH=wR8H4pz;jy<;tE!-%*Ik~?MO*jVB*vSZs#Iu8Rc zteqQDKT<+0K2Q9}fWb*c6?4WvB}pt~;+DEKyl0Ce2i~krfIsA-X8Q`}=K^Z3@%)u9 zTKoay;_qD;&iw55(r=uD?f5W_z_tKCu%l{X8LcvI04nC~zY zo01Zdqn3zS*cGU9p&TT}0x%bXXq)VIA!v@8e{=h}m-~e#uqiM#gAFagQ_si|z%OIdk0{fbrbGOTJj_`Kw+oL%xW|&A;-mMxgDujle@T0^0)cAv@t) zcl!unKCpe*2QnkM2mfm*L-C3NJo%Weu)>pl~vk8 z-h!Kr%Zi?(cj#(`j1fR_OrC1zAbji+;njxlkF(p3)s94MF$kR3yT9j8zw`l&i@*24 zdmHts{CcQkw`p_OM&Ldlfo%bJpP#_3`L{m;r}3#^%>TGO#HWHGwi^s(mnsu$I)o;h z-0C$;U_8d7-M*UF5O6I-XG$W)i#tZM+-lt9+Dp9PDlZO^+3kqBE&gP;jZDk6-h=On zU2pzFydjsm)DE+c4f4-C1D1;YLXCDd9-XsTj5?1Aj<)DG-45FcWNz^m$BY5HV|O00 z!LfKtw6)`;BU$sK8r06A1aQRn`z*PmZ-!I+dZ=wMuI53hxn)a(&JpCfmP2uFb_WK* zJ+O^Fc8M_wozHFku>+zOfB^VuUw!BU7~l7I;pcw5Mc#yM1Rk~#*cN~f+Zo^bJB+}Y z^KtvSdCWy%4>I2Vcq3Z5?R9#)RRzP@kF!?GL@S+=UyDZtrM1!KdL6qIZtbBMzLVm`5PM!2~6{*8&ap^g0UW z0Oa~%Y+r4jYx6?Sor2}W z2RCksc{7$?u%^dik)e5$rN3kx>!L6)a-!8uHy=RF2Y4#`gv6<6q4o>lHxKKn&z%1LF4*+AX!xozS(=ggI0jh>W$d=34@XDcF z4b2wdWD0HpDHau4==-o4mqYP!ynVcxuWTT*57k$)@O~Zen$&dz8p*nZ!&8|cp7KUtgrd6VdXXkb3{2x zEZ!KsSX1qrU`AmK-4qeiynZw7H_T=e53PYCed7_jIj#>{1lB$%#vHH3P5~9mkTZ1| z^3d!Qz;1fA1C%)#U9NaI*x#|}Sovhlx{(=gdJ0iR8zWmDVQAc9FjKZ9m`B>G43Ou+ zrArfEJhN;IHjlHPLKd)yEy(O=gYh*Vb69sCdp`cNb-WIV;vc|R?3L>mn666%^{;%y zhd#LYiw}Lgg_{4ivW>vQJ_6eU@L|6Kw*G4b&fp88Z{}CRw1o{UzD>;aIBd2q(RItn zn>G5>2kkvhmu5fQ*K(29YqHrN-xR4=tl1Lxjkb>gZ?fq>aU4rEZ^p2*81Hh}j5|l@ zlP&$}>q5rpV@u{zVjYIuWZ5q~BA2~sW67$>$%DZ**COR1{MRhSFU?W){LKr3W!DUE zrm!@O_TG{KSEB+M@J^THzEhdm)vwtJ=7YY~pBxrbLK}~^)!LZ{&m=fUt1~{ivGD6& za>0RPMn0bh{?LaOe*iASaPihnedyz&JVLL zyrf5@?oNGP_{B9IczoC+u5G>r!VAEwkolpHOIQT*rC)vMLtoK(cfY68?ceHc1n%V# z*cO2I^4Zzi_FW$V->6ZSC;(v}9b#HZ-de)H}3%|(9>r^}>B<>F~F~1jqT>SZEU;WaD7JoN) z-^Tm@@SQLCyN&<0vW>tt0^0&`y9TxqC?jwd9|+m^iD2KLOsDQ5nRg+Siran=UOC5? zm)U%ijGBwWzOW<4MIyZpe8A{9dwDP{Y{<35I651ir?)?z17?2Z|6jH+8^ zO*grRXh@oT)40xouME)#I&SH>EkWz{C&^Q z|3vS|CT%0IjetgATL5ks!ZrdcBe1vufiq*&S&TPXcQD?l!$#c2*j(Fwl$yR{*6I~j zFXD!pH&IZHQFA+lLE(+mSgqIb+*y=gZ&CQI#DSsaZ1IKLzxWk=>>)VC#yBpN!xpG? z8=EZvw}o2cK?u*e4wSLYU#(@2KCIdcm=PEG8USvlqt_(U(#OGe^TSFsirO8ADb5^s z(h(@#GEnHw;l<u7&(%8=LCu0FL>B{ zj;b&Hk6P&GGUR}PpK3PN>&Qf1_r+6PrxXwNG`jOVU}hiWMH$2#d$xh4hF-`5@|=O|E3@+E7}gos zIh2-$1R73^1zg6A(j_DNYBRa$V`(~NC~P?~%e+40kC)i_<4eC@{PD}4-vr?^zwkMD zY-JmPZ3N~KIQ#Tdj|+S2?TfQ*)3*_Ls7HW{!0T89`XTnL^M)4Xcc2N?TLvNBU?wBi)5!~iqALYiUE$R8-NmLIauAFXi?V<}{g z?e=Ri)iQX1$o57nR%+R!A2r6nhZ5HXTd~3=8Kvf-?igK;K*F=3OAguL%K)uE=xE4s z?UK`MR8Xu_W?UEH2~<4nBeE}sW4YEj(^&KO3yM105Yu5&O=*6O1A!2b8sE-l5 zwTb+IL>NbrMm#`f)k_C?4p^#P5Q5%HqjQ;~^Oqp=c^N$0NoknmV6wdoO`ADGE&CXE zu+tBmgBF3HEEb!>=t}`5^5>B`3F1J6#W^jr4t5CQ>DmlN^)r_>OFz4g?^a~qmNoc3 zI;Qox*13nTd8%<>O;mO2dOHH-c;6pi`sGi(@ppdlbuW1EeShD} zFam7>Xmnc$dL*`b8-W-BE^v4k<|5FGowzmSt`TlPbs-=+4!$+Q95RHy2&7^IJ%T+z zgpX0WZ@H6h?^!9x>C1n9W+(8Zjum&iKa$>$>>! zFMYg?#oxDX+}wQ#_QOi;+hN?zzKy_xHUe4zZZH2nXs31S&qObh=zOYvg<)l$u6vwWtb$)Lv1IBe0;W)0e#=|f9M$Kz0Ui!sz6GSim{GpE@_@&P; z+O!5R_EAdj&r#SLXB&b0V+77_i@*EhL~X5ne@Eca$4+4p7`F?)2hKZRaeLag6WDL$ zY(2f_8;K&~P}gB|PuKirj`f4MwDEmYLoTmgU{l*tr_7tIN(&l`HQks&;7f4y@_Pc! zuREo65y%_%=^;7X5#n|ifovPw$yVrCS2;ksEzDfW@k|AuK7|uqNi5Erlsv300>gBS zMIbWs{WAimzyJF_dH=L_Yw0!u@8bw?5y%f!a8bjXl4{ENKqZ_&ydhwR(KCu0Ipfju+4!y~ZlU(?RPef5Qo9AUmMvS>a0vVG#$n9r z2gN$zl$UwC4IKH<%lxrskRCJgRj%#ET${=G#`e}AC2h4uooC3;- zn~ZpD0~2L3W*E7%Zy%S=%XsPAwUOI!2u_)4yRH~<9f^~=`Mfr`;Nu@Jd@F;$>{&na zhD9IW_xCP-=l2!7?EAu9Sp3CkE|0BmOVfsIBXD1gfL;RjBJjoEed)ev^w!921m3R^ zIQQ7;-IYsN@?iG&&R5*>*rKw#!yBGs@JreI25Mi-SSr`uSQY8_Ta1B|i_k$X= zS8$F>a6aDrZ!C7=n4JHU1x`@xr3Pf9vte1Se=r0_48Uo)bAiZRK^4G#_tYS2;kWm4 zFr%=QY>tk{+zITyh!f3LB%Za{-*fD4w~y;!K!ZQWjcu`QQDbb^b^@8SakCxN8Lh@6 z%RE}gHb-vZ@}$(`T4v3dJhU7kq~7C1KC$T$ev)Rz;9A}NZ8f4A2iEMVYUz+s>kkat zc-^ArLhu?MSFq^24D2l|{QeM+fANSf`#S$g)@aU9awrFEQ??Pf*G6FY5XiO=yw^_8 z)}DKD1nMGioalWUNR%o>FaJ_)U7B?oc*7fFlH`Iry%!{gj#E1n4`DcTTOT^PWBg;C+T$*`Ad<@)?~fn$_{#Mg zyFdBMKLzW(Q_##-Vtksq3EK$VGb1o80Ntu>A?T6V>IZfN>I{7wo#Z0$GIs(|xRBw) zH&@=08D9G|5aLE33Ht(W*5QyPETIfH4;ldMwL8Eewf*_hS~Rp|Q4SpxMT-Kj4Dwdr z!_yzW&Aq=zkh^>=`p^t$eD5Det1bF)1HT^@f$RoHgGVm{h2;?0-s?E`?L^U##T@Gq z7=d6IVuMh*9Db$CSebg!F2-$s3=4%^mUN9c0j=6*uBZ-D;`GQ>%D^j?LXBZ1FdD-+eUhb76g}w-I>1Mxf2d zYYp&1aLu-1+X&pZBQVX#ywR-vJFtI$tVn_shi%EIcD7ZfXE~N@sHPSvT$& z)t8tvL~U=@$1U_=+x@;O-DIK1O&2%cwD+5EVD3+A0fc=V#t;t&)=ejlK!aYm`*6h) zFM8bdJ~A-pD1noC&FePiCvSfo`*t5jiP);?uNVarBjTE)8V7nugsE1HuvB}M9gF!S zA|upItk9q$Ae(^QU%~E`CXr}EQJHXOn){eG`u=kIs10Q~VU@MT{z_m$cUat`7;M&-=@=FD~V znQaHhsGbKi=eV1`jli9cz@ZC3r`Q&P9*M2KFGrxxwCF(Ger~TP_Vq3HvGb>Qm+*2h z@iPd?#SI^)aMRK)88f?H7RKG0le}-*5Mhr}C;!0WW?rqtD=%G#>L{GSAr;j^wl!Jp33|N5Uy=a!BCzp;sz@9=4J$Pe%vr6KI2Uq-ORt&F6wWJ|@hPB08$+_6 z46>02W?qjB-2H+Tx=~<7tc;KkKeGQ(lsUoyBkTtke3au;zj2Mk?({Aef4TGbJJ)aS ze(J9uow9^rlUvNnA->(Z3NCf|H+St`Sq7xJCet?5IoW_ zZKS(B0(G`LMy|NUtT^;L)Y9l(vB^?noyS+iT+)|CyJ4|t;*HpMeJ>nz)6Q6)=;rDVvs$_m+EsvKM#3$-`3?+T9Z2aHb!)7Ohm zzE6;2bRvsD23C71)Dk&rTiI+YcI|*nKWB2bbeBq~q41OzacLVkaOmbM+8|x^}hXp^H zy=L0moTG)>SlbBf9RchB#-;ZBCqFFG*I#;lFS#1FgDWy7fUK6|y^xi+zTmbTm zo-gC8UcQY*AHU=8x3B>GOMm@mGT&4&*h!VOSamRtoaeTj7SU^SPA1|a#Rfd2Bd~Z0 zcv}cQq*ubd*6lj$O>NtEtC?sAt7TCasc@`0G1GDp#WGB~#V-=j+8rOmdjj9%djhq^ z!(A{uv~qHM@Z-O{!aMvavpAY=^QpwTrCTbGvVAE>V2)CtgH43}2WIGtKqzey3z>b- zRbaqmBYIY`K*13@+uPm?n07ASi23Vc7|IK}wg^N#%51Z3?hn!6!RV5)LmiaXE>jz4 zS_D=*^Xe3%gLPpi+{8@NvCeJ&0Mv8i#6<@^4 zPr2k^D3;!bu7}ipLa#v&WDImFe!en-u8qW~$HFgm`*?2slCO9E-o(Q1_T(Qy+_rIn==KN48gph2h-L%8v58o@qZjU`hOKEKC}v_c zcf`#&#b=$Lu~20E z7k@8d;rAu%{QUwJf92B1c7V7j(;znc8n+oC(7xttzZIKJ46`uri%0UwlmpYBOo}ba z13m(K7l7{fwh;74JmhtqZR$`gCcbJsK;9QGUE(%zfWJ+7lj`A8%4%QpWNtS&WNQH| zu!cAP8*S{{9}J;DH@W5lCvG~lk$?i6cB*3v`T|Lw7ZsIb zV=@`dP{`BKDJvY!itIf0_XI-0AVfa416EY6O&TC*;RjNn-x{#vXwme^qCLDE>31phsnM(yQ z3&$k8-F(ARj~Nw};^Lq>1BYxZ{Dh?&n-^jN8F~@J{Ir|h{b3MjOA;3q>=Px_5%hD4 zXP*{5aHtDE?f`Pp7Yjfy{%-Bwz~b*~czh9yzhBYL9~*%<45EVGv4bdCg`zAe-gecJ zln666W>wk=S`1Q&wk|FLtkYJ|+Pd41vJ!ZM9_SG`bOGqzb0K)t_XpBvdyk+;?g6Ol z>@s(Uw&Tt?d*a1yVotC}&pSa2)$a`H5SF;d$HIz+nXjAzanS$9P#kuxAyCv+0q%6R zT39fnDYvi)ys^7{mCL`tx$x1h6$nIPUEw&;8{YT$!*$g1002M$NklyElV{5M=l;h{~R6T42wWU@+waq`2X2^^IzMpBR%XsQW762k$fbjmPCo7rj|PD zb{c7PoH*?uwqZDl-LQ?sancFefPutD{|^S7pZwS5hX6*BP9&pSYLV1Hii5=AQ7yjI zS5;rFTGJlRaHn@S-`%T*ufD3a_C9+T_c`}m&*icvil&N1`V3@1BdARvXjHdmKpR4d zD>m^97Jq6F#_EZm(#nntBZ4-KNUnGY+>#-Q^hUtYS5RztQ_|!VtHg8K#%V|%2d#?{ zQa4Ix%F$Tx!XV(_aSp~qRT(6dT__lfc8KEgF2`qkEqioFQVbyl)CBBtUg1o^ae~YS zuxk9ohrjUgp>%|A$}fEUP@eq#AJPE+tAB~#_5}+n&6|>{0bbi2(?V-6!Gp0d4V;Z> zP}9KF1g)rR1he|;31?L8SxJBAe(Y9Ya|4K#+50pQF?Wq1R^bM3>1u+Dc;4EEB>T3= zE#H=y^a#MJVb>yBuFfuevWHRb(B&p71_g@EN9YkPMm1|O>X3M_nG7CP6ADm#sc~$H z%88tdh`V%Mt6YXxOA(GFMfbn-o>YsA;+* zSFB2uCW5AOX0P9t$3fa43N?Ic(g2V1CQ#h?PC&l>YYm*>k`2YF9mUv!mqrgxFM$my zut8P#VAw<_iKq}kBTz0J2gHl~Yl>G46S$(m#T^-J& z5s5>|n73fO1UHl%Fz8&ulei?O)$h)wAqpLVe1xt-^Vn`1FaN1hq{dpZe(+Kzljb*vX{uizE2Q zMo*hO6s3z51;Sz&UkA`AFM_ct-o&zZWAXtFwc5fUg3>vsfC5j97|fOo35Vr^hYcg+ zIU+A*j@gFbuygGJSlIYWCkA0t2^Mawl{&^`>%?LRNd(9^-*7Hs#4*D;#0D_`^%*pN zzZMLh{OO-|lVAAwzxB!A-$!zdR{^sHakE+V;>gno%+TmwiLrm8%WJEFMk*CTlNbRu z*hrU!6r$!WF*$y>jD+(fxtuv_chb9n8@>YZ1n{aF!BNB4t|>8hjbOVnSFg8?#i$~? z;PA3DiO?%lzD$jncn=UL|e6X?!gwe7>vaLiZ(i&881ug_e z_!NsOSc^@jO2kdPKpd+BcJN6O%_F+Vc0@`(NkRS;4(q%uD@c8C&3A$4S%G{1=HLDAJRhnId!7g~aLZ=|Kl|DHgzNZ&AG}uSTa>#- zaLXz!V1`G0jvw8+=IF79m&_s8%GI|nSF~hMzR*MPB1V@uiC{h%H9|*`ObJ`e1o-w5 zf#_r>N8o3I=lRVi_b>2&Uifq%I6w`yEQ5ml)tKs_9awI~RwVdTt4$1v(cH@*G-*!? z1hwO-oQ_!+85s2?8NU&1cJo1(e`>Q)=I_JIh^_3R#F`}@Tg%#~af)y3VKZ)_ z@f>z6plGbyiyxBv08rUuR8~{OlE-k3Pn=F6qn=3$C^k`5DL9;{^c$yX*=Ba7(p~Q> zxeBV41Y`dyhehVSxE+UA6g+szu<^razu{|d{qikeG=Cq+Z~Oj4n!f)}e*VFq{$0zX z#pd}|>3b!;CVFSK*eb+YgaAyXy+8Q^!CPC~bjWFLCxeD2`ab_L=IU|S)c z2<~kH+g}uEo4qy%^lyLrD{9`;2;Myr9IfOAn_aUv01L!hq+&QwTIRv!r!THpL@G+E zAlpZPV#5t7NVE}iT`6>@{=gAq2D_sn1e=zdZ`UxA1M+y&H4wggk3P8g>wEH12jM{j z)o&=4m*gyq8Sxsx{O}9f??|R0nv%LjlbaSzq=pMN*G)_Y3$6CECxN^nmWObdrw}W* zaE1&z)6vwG&jH#KzW!+-;PNy{5&(}4HRvyhfX8dsINF($P6Wl4=9k29tKg2QKcNE? zf;v$GEV{XiHjb6Eptb6V1xOSe&-`$be&g~4MyPwmCp{J_<*D42BDt6#;ft-z&0yu# z4NPwQ@DwoK@|7HD{(dbsYyKWxydj<8pUda{{?WhqJK{Aqo2YYQf{U8~gmWEcD+Mag z;(|r$>m^7+7-#O>!PwwCVB?>tE!{yhAlV+6Q*Mu%)PHBbF)J`_05OBTZwueQ|J22I ze)|=j{A;iOeAgmuZ3LfvwtPmA@{eD8b62iAu&qG3Y@6QxKX>at+q&V3ldnLPDvriy zb>!+C5YS&(=*K z+_E0Lf!oq(`=-lKma4w#@u{XBLjkc0;BDjF%~|Z?^_;+&m<$~gKGWq1l*DRd4lO>y zMTo}FWdJ=+MfbBF%p1)ea^NW;nmzcQkYmy|fo9Kq{u{wLza`_ukh0zaTbm;Qerznz zv}%v0S7D|5t^K0;>DKXp7DvX$Dy&ynOY70vW=?`@ziLqwF?ke({S%|b+TJiKhu;vG zhrR7BU8*UjI#vYm}swg8AgcfZv# z_|1YGpZw;Xi;GV`=$pVgNNzYxW|w8`X#tcMYu2s0+~|6YS!`dpa=MJ5y=F>HpeC2B zxzUSiP?xZ^J|ysh`FTHlQMNwW6PJzFOz`brwaIZo6Nu3KG!RRJW|14P#g>wV2mzZ1 zHLCfrrvsv6Q=WozN#OWQXNeks-H9Hg;5sHXAiBkjY#nGo2cA+*%bEjlEUBB2b-MBh zKXlBTPyEWCbhE$btH10az>fv@fq?L*-M;rHf22{C4GTine%@-qqNOb~4GT>Wg$I)^ znm2eUkFH9DVg`a?KoL?_h>mNla}|VF*KP%-p8;H| z^u70fwd5Z9wl;##;Lj-d_7{Ko6N zvkNY8Z5A_BO_!S7p5e}?a}OTTh!?>`j3T+6+#J;b-{`8SVH%h-7dc!e=$>`S`1DgR z#%J9SgcM8@vIw+6vc@c1N;*p6^jIL&?WN%! zH)-R?&-$SO*T3b9QGWQAFP{9pF99$8^Z#6|+{z4ms&q7C!BvjI`k24LhiXEoB&(9! zxuUKBhTsvR+dnew7+bELpw|eC(eHE?yuOo$VnQ=pK)|L3Pn|DDM)3NHsC0!BtfyUunijSRv)6 zRO)Fw+N+f)@dki5I5QZB`7K}Z*Zhftc|CzLl^xR#Msm|t@_z8qn-|aA|2*z1`a?uD zY7VpD25+i7x9}^oy_rbAjSSI0<0dA&)mC4~$s<+)*FNK@_6AcEBEgR*fcTATB8P4wAh~amn#RBpfzgbL_4BO6rScugIxWmd1l1BnNj}clP0$N--n4;@HA@!s_AZ%qL z7hsIawj55s+h(vFauOMdn%rVx`rdPrq<18jyaES30n9b-Z3Lfs3SWhBaq*L%?El=} z)<*DipL;r&c=6}|?_X8=tyK8RtR`13+IYbZAr*duGP+9h!a4G-P}VhxmJC{b=ph)R zLfs~Li&q^ftNRpSC@Yh)jkp0=KH|kr-teJ3*g@}cIg=3&eF0;*8k{3j`GNZE(`W?E z0TX{9LB`gOFwka*vM*7N@nbf_XQnA8IUw>WpFRx`)qlyYiPtJR?z9TXvus9+lxRjw!X6u3b=EUHXwLyA?% zyoR}FmGlm9#Vc@h1IYSEjUbynV%qVLCJ-ELBiJ^4Z64~kwh`pj#V5*(Hs^h^kyrf{ z!ctC=ETp|uh`)s>&}X2&1gPL{mzo06)E6EF7|I}RtmycsPKi=CMhLEF*)!HRQ0HJZ z&e1NFnrWP2kV}3(_qi|XH5{wPm1|{CnXfb;N-xHEdf}jqKtsi?NVJSbMx*FKgqzUZ z@hxF~O^h{v`R!peaO1axL8Cro5k(t4wIOT&HnHLbh|ywgY#YY#CoFBq0~uS`OclLw z!`b_bjqy4(G1vYut0hGC=7oOAjbQ5&-wFYWHat3Y2D z&{f7L=cs+X3r946@>e~?hvx5Nnau|h@cDoChoWJ#B3O#*MKMUVPk0=pYI9$hLlD{| zOH9#I-{v(OPOCuY)2^XejGk1*fCk;E+{y2~&p3XC=U6 z|7>)m44#WHAH5QdDXxOGFmPG!=bn36?^jqlG<94;9c8C>B{Glg3$yBO%$M8T1Ona0 z6^33aJg7TGiioC78$prL3~D2oW!O}gh7Qpllbb>@X`GJk4_ngB=4&8CY#75X0%{H2 z^U(%-`vXH~YjDcyIB^AjRR*zaK6GRD9U_;Qay}JXd&L)UqzlZXJU-WoM-tHZ4I{9)26%1EJEJ@V02B_exS(@whrCv5;(+Nm2s zHhko7$5S{EGi!^^nLGn1K{x$@J%+z7Hk zv?kCRM*J2q^1;VOu;`d;`}D8XZ9S}^tQ$e)ksUZ7^M}85VxD5BpYy2n6tDh~j)0Nf z%$lD$i7TU>C{CdfDkn~-aUN<`f6+|JJZceoKSNDz&O4r_JZGWXkDZ6Gl_r<09wnAA+Oab|jDahsaO)F@JB!DqFgSPA zDvt2cXa?Pv9i2yvLIyr8vTh-`F12YsS67Dtcwnk%+LUyz#SwRwu{|bdN%Gi}o}~fg zsvgq_t~Gtkk;g+CL3~+t{@R($wtG{xJ=?Ytz)`im&+#_#^SI;oh{629I6#~I0vTiwL-8< zkbNFdMoAn^pak$Uf%WGBB~oIn3A9Eq>!_h^4$UUD3ovBhM1cjiYzH5#x`ysEn<&&{ zw0Im0@~A~Qj2Cun^k+Hy)b4S_jK*v`L`DoY<~EIcJv(t$%9P6aH`D>qA3FzCmKgqq zFB?C6q4P)b^P&9wN`79+zu}98OSr@ z#*zHt3n9{+N08jRQf&YOq6dDV;tezl(MP({dLPMj7d~HsGd6(b?0WbRp9opro_gxj z9pGm_`$cElVpwVfr+=M9%KgEA*#BqgP|v}QU|FJ=R=F&PsT1sV$yetjLb(SCIyN`( z3L_WsycMlTs~3(;Fa)!90F77oDFy9HCH8KXUS|nWP-IrTIt^h@^N=nvRUC{LzsFhQ z2RwYRXRd-%H+I;FF(VAYryN6!8nM%j`At!VaG;jw9(+w27;8$bk--hZE|}Q!P$H#lwzm=yzx98{uigh%NJ)rP$O@fIAyits|Q{a2m^h*mRF` zS~b_|z5fw!`A)MNsUJ5}upi*n#IpwwVv;RF z=`%w>TV8h*v`b+yp{OB&ShkcqTp<|btb562|1=AZ3%79#Ll1^ zUdk7#C^cyAP~0diUkAb;0>Ub#G*CIS&o7W^zPgI)Ft52p79sGETq$_Vog2Mz6DScH z3l8TrgI36RBp{AFnn8_`+{me^!vFy^9waV#UyfZ?g1JrOK2YRA(-jJfdxW!5=IG;SuRQQ{46rLegp zE_@;Y8Ftkz1{nBkG@F=APq?tTFY8$bPu3}ekU7Gp$U3K+6l=1bA6B7Rn1fi1y{(*A zdfMTf^Z=Dafo?YQ0Y{sS)&SxcJn(fcAARzPKJmj}^LS4h!QYX_PwR^0A$X>KXg_~y zh!m$HXDdp$OektdLgBtJh2Zq$gPMFHnh^W27krp{!E(eND;J=!9xe!1ONSKL1}<~B zvnczeHs=-w)-}Rz;LAs8L~&<3-3lDg04^Qf(-qb-?P&trzfkhpYy18z9dmDK1ZRJ# z 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 literal 0 HcmV?d00001 diff --git a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift index 48b64abd4..d78a60320 100644 --- a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift +++ b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift @@ -40,6 +40,21 @@ struct Render3DMaterialExtensionConstantTests { #expect(PBR3DUniform.dielectricReflectance(material).x <= 1, "The tint cannot push reflectance beyond total reflection.") } + @Test func clearcoatDefaultsToOffAndClampsToTheUnitRange() { + #expect(PBR3DUniform(material: PBRMaterial(), descriptor: VertexDescriptor()).coat == .zero) + #expect(PBR3DUniform(material: nil, descriptor: VertexDescriptor()).coat == .zero) + let material = PBRMaterial() + material.clearcoatFactor = 0.6 + material.clearcoatRoughnessFactor = 0.2 + #expect(PBR3DUniform(material: material, descriptor: VertexDescriptor()).coat == Vector4(0.6, 0.2, 0, 0)) + material.clearcoatFactor = 7 + material.clearcoatRoughnessFactor = .nan + let wild = PBR3DUniform(material: material, descriptor: VertexDescriptor()).coat + #expect(wild.x == 1 && wild.y.isFinite) + let plain = PBR3DUniform(material: PBRMaterial(), descriptor: VertexDescriptor()) + #expect(plain.bitPatterns != PBR3DUniform(material: material, descriptor: VertexDescriptor()).bitPatterns) + } + @Test func nonFiniteValuesFallBackToDefaults() { let material = PBRMaterial() material.ior = .nan @@ -116,6 +131,18 @@ extension Render3DGPUTests { return peak } + /// The largest brightness a frame gained over `baseline` anywhere in the middle of the frame. + private static func largestGain(_ image: Image, over baseline: Image) -> Float { + var gain: Float = 0 + for y in stride(from: 130, to: 350, by: 2) { + for x in stride(from: 170, to: 470, by: 2) { + let now = image.getPixel(x: x, y: y), before = baseline.getPixel(x: x, y: y) + gain = max(gain, (now.red + now.green + now.blue) - (before.red + before.green + before.blue)) + } + } + return gain + } + private static func matches(_ left: Image, _ right: Image) -> Bool { GoldenFrame.compare(left, right, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance) } @@ -162,6 +189,52 @@ extension Render3DGPUTests { #expect(red.red - red.blue > neutral.red - neutral.blue, "A red specular tint must shift the highlight toward red.") } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func clearcoatAddsASharpHighlightToARoughSurface() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.9 } + let baseline = try await scene.render(.none) + material.clearcoatFactor = 1 + material.clearcoatRoughnessFactor = 0.05 + let glossy = Self.largestGain(try await scene.render(.none), over: baseline) + material.clearcoatRoughnessFactor = 0.6 + let blurred = Self.largestGain(try await scene.render(.none), over: baseline) + #expect(glossy > 0.3, "A smooth coat must add a bright highlight to rough paint (gain \(glossy)).") + #expect(glossy > blurred, "A rougher coat must spread and dim the highlight (\(glossy) vs \(blurred)).") + material.clearcoatFactor = 0 + #expect(Self.matches(baseline, try await scene.render(.none)), "Turning the coat off must restore the frame.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func clearcoatStrengthScalesTheCoatMonotonically() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.9; $0.clearcoatRoughnessFactor = 0.05 } + let baseline = try await scene.render(.none) + var gains: [Float] = [] + for strength: Float in [0.3, 0.7, 1] { + material.clearcoatFactor = strength + gains.append(Self.largestGain(try await scene.render(.none), over: baseline)) + } + #expect(zip(gains, gains.dropFirst()).allSatisfy { $0 < $1 }, "The coat's highlight must grow with its strength: \(gains)") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func clearcoatMatchesItsGoldenFrame() async throws { + let (scene, _) = try await Self.makeScene { + $0.baseColorFactor = [0.55, 0.04, 0.04, 1] + $0.metallicFactor = 0 + $0.roughnessFactor = 0.5 + $0.clearcoatFactor = 1 + $0.clearcoatRoughnessFactor = 0.04 + } + switch try GoldenFrame.check(try await scene.render(.none), name: "pbr-sphere-clearcoat") { + case .recorded, .matched: + break + case .missingReference(let actual): + Issue.record("No golden frame for pbr-sphere-clearcoat; record with ADAENGINE_GOLDEN_RECORD=1. Actual: \(actual.path)") + case let .mismatched(comparison, actual, difference): + Issue.record("pbr-sphere-clearcoat differs in \(comparison.differingPixels) pixels. Actual: \(actual.path), difference: \(difference.path)") + } + } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) func defaultExtensionValuesChangeNothing() async throws { let (scene, material) = try await Self.makeScene { _ in } From 5b67a2051906b90006516835a2c5de26ed8740c9 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 02:38:33 +0300 Subject: [PATCH 03/11] 3D: add KHR_materials_sheen and KHR_materials_anisotropy (factors) to PBR materials Sheen uses the Charlie distribution and Neubelt visibility from the glTF specification with a constant albedo scale in place of its lookup table. Anisotropy uses anisotropic GGX with the authored tangent or a UV-derivative frame, rotated by anisotropyRotation, and bends image-based reflections toward the stretch. The sun now goes through evaluateDirect, so every layer applies to every light once. The importer validates the factors and reports sheen and anisotropy textures as skipped. Adds pbr-sphere-sheen and pbr-sphere-anisotropy golden frames (Metal) and records the cost (about 8 percent of main-pass geometry with all three layered features, none used) and limits in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 35 +++++- Sources/AdaAssets/GLTF/GLTFMaterialData.swift | 16 ++- .../GLTF/NativeGLTFLoader+Materials.swift | 17 ++- .../GLTF/NativeGLTFLoader+Schema.swift | 13 +++ Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 8 ++ .../Assets/Shaders/flat3d.glsl | 96 ++++++++++++++--- .../CorePipelines/PBR3DUniforms.swift | 19 +++- Sources/AdaRender/Materials/PBRMaterial.swift | 8 ++ .../AdaScene/3D/ModelAsset3D+Materials.swift | 4 + .../GLTFMaterialExtensionTests.swift | 45 +++++++- .../GLTFMaterialExtensionMappingTests.swift | 8 +- .../metal/pbr-sphere-anisotropy.png | Bin 0 -> 95800 bytes .../GoldenFrames/metal/pbr-sphere-sheen.png | Bin 0 -> 114088 bytes .../Render3DMaterialExtensionTests.swift | 102 +++++++++++++++++- 14 files changed, 342 insertions(+), 29 deletions(-) create mode 100644 Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-anisotropy.png create mode 100644 Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-sheen.png diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 45b1d7078..6588b0b0b 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -297,7 +297,8 @@ Each item needs its own short requirements note before implementation. - [x] Stage 2: `KHR_texture_transform`, `KHR_materials_ior` and `KHR_materials_specular` factors (see "Stage 2, first slice"). - [x] Stage 2: `KHR_materials_clearcoat` factors (see "Stage 2, second slice"). - [x] Stage 2: parallax occlusion mapping from `PBRMaterial.heightTexture` (world-unit `heightScale`, per-pixel ray march in `flat3d.glsl` that derives the UV shift from screen-space derivatives so no tangents are needed; the height map is read with the base color sampler because Metal has no free sampler slot; depth, shadows and silhouettes are not displaced; no glTF import yet because no displacement extension is ratified). -- [ ] Stage 2: sheen, anisotropy and `KHR_lights_punctual` import. +- [x] Stage 2: sheen and anisotropy factors (see "Stage 2, third slice"). +- [ ] Stage 2: `KHR_lights_punctual` import. - [x] Stage 2: unsupported-extension degradation policy and documented supported list (`NativeGLTFLoader.supportedExtensions`, `GLTFImportResult.warnings`). - [ ] Stage 3: morph-target asset, glTF import and GPU evaluation. - [ ] Stage 3: morph with skinning, shadows, motion vectors, bounds and animation weights. @@ -792,6 +793,38 @@ highlights but not a sky reflection. There is no clearcoat debug view and no Web `Render3DGPUTests/MaterialExtensions` (a smooth coat adds a highlight to rough paint, a rougher coat dims it, the highlight grows with strength, turning the coat off restores the frame) and the `pbr-sphere-clearcoat` golden frame (Metal). +### Stage 2, third slice: sheen and anisotropy (2026-10-19) + +`KHR_materials_sheen` (cloth) and `KHR_materials_anisotropy` (brushed metal, satin) factors. `PBRMaterial` gains +`sheenColorFactor`, `sheenRoughnessFactor`, `anisotropyStrength` and `anisotropyRotation`, imported with range +validation (sheen color and roughness 0 to 1, strength 0 to 1) and carried in `PBR3DUniform.sheen` and `.anisotropy`. + +- **Sheen.** The Charlie distribution with the Neubelt visibility term, as in the glTF specification, evaluated per light in + `evaluateDirect`; the base layer under it is dimmed by `1 - max(sheenColor) * 0.4`. The specification's sheen albedo lookup + table is replaced by that constant (0.4), and under image-based lighting sheen is a diffuse lobe from the irradiance map + with the same albedo. Without image-based lighting only the lights produce sheen. +- **Anisotropy.** Anisotropic GGX (distribution and height-correlated visibility) with `alphaT = mix(alpha, 1, strength^2)` + along the stretch direction and `alpha` across it, so the highlight streaks perpendicular to the direction. The frame + is the authored tangent when the mesh has tangents and the UV-derivative frame otherwise, rotated by + `anisotropyRotation`. Image-based reflections bend the lookup normal toward the stretch (the Filament approximation). +- **Layering order** is base, then sheen, then clearcoat. The sun now goes through `evaluateDirect` like every other light, which + removed the duplicated inline copy of the lighting model. + +**Cost (same machine and method).** With every layer switched off, `bare` main-pass geometry went from 1.77 ms at the merge +with `main` to 1.82 ms with clearcoat and to 1.92 ms with sheen and anisotropy (32 lights: 2.18, 2.26, 2.40 ms), so the +three layered-material features together cost about 8 percent of main-pass geometry on scenes that use none of them. The +branches are uniform across a draw, so the cost is most likely register pressure from the larger lighting function. The fix +is shader permutations selected by the material (the `HAS_...` define mechanism exists on `PBRMaterial`, but the PBR +pipeline is keyed by vertex layout only), which is not done. + +Limits: the sheen color, sheen roughness and anisotropy textures are not read (reported as import warnings). Meshes +without tangents get a tangent frame from UV derivatives, which is constant per triangle and shows faceting along an +anisotropic highlight on coarse meshes (visible on the primitive sphere in `pbr-sphere-anisotropy`) and at UV poles; assets with +authored tangents do not show it. No WebGPU validation. Tests: import, ranges and warnings in `GLTFMaterialExtensionTests`, +the uniform in `Render3DMaterialExtensionConstantTests`, and in `Render3DGPUTests/MaterialExtensions` sheen adds light that +grows with its color and tints it, turning it off restores the frame, anisotropy changes and rotates the highlight and a half +turn looks the same, plus the `pbr-sphere-sheen` and `pbr-sphere-anisotropy` golden frames (Metal). + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift index 64037dfea..82016608f 100644 --- a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift +++ b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift @@ -52,6 +52,12 @@ public extension GLTFImportResult { /// `KHR_materials_clearcoat` strength and roughness. Clearcoat textures are not read. public let clearcoatFactor: Float public let clearcoatRoughnessFactor: Float + /// `KHR_materials_sheen` color (linear) and roughness. Sheen textures are not read. + public let sheenColorFactor: Vector3 + public let sheenRoughnessFactor: Float + /// `KHR_materials_anisotropy` strength and rotation. The anisotropy texture is not read. + public let anisotropyStrength: Float + public let anisotropyRotation: Float public init( name: String?, @@ -76,7 +82,11 @@ public extension GLTFImportResult { specularFactor: Float = 1, specularColorFactor: Vector3 = .one, clearcoatFactor: Float = 0, - clearcoatRoughnessFactor: Float = 0 + clearcoatRoughnessFactor: Float = 0, + sheenColorFactor: Vector3 = .zero, + sheenRoughnessFactor: Float = 0, + anisotropyStrength: Float = 0, + anisotropyRotation: Float = 0 ) { self.name = name self.baseColorFactor = baseColorFactor @@ -101,6 +111,10 @@ public extension GLTFImportResult { self.specularColorFactor = specularColorFactor self.clearcoatFactor = clearcoatFactor self.clearcoatRoughnessFactor = clearcoatRoughnessFactor + self.sheenColorFactor = sheenColorFactor + self.sheenRoughnessFactor = sheenRoughnessFactor + self.anisotropyStrength = anisotropyStrength + self.anisotropyRotation = anisotropyRotation } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift index ef60dc20c..5f79c1677 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift @@ -88,6 +88,12 @@ extension NativeGLTFLoader { let coat = material.extensions?.KHR_materials_clearcoat let clearcoatFactor: Float = coat?.clearcoatFactor ?? 0 let clearcoatRoughness: Float = coat?.clearcoatRoughnessFactor ?? 0 + let sheen = material.extensions?.KHR_materials_sheen + let sheenColor: [Float] = sheen?.sheenColorFactor ?? [0, 0, 0] + let sheenRoughness: Float = sheen?.sheenRoughnessFactor ?? 0 + let anisotropy = material.extensions?.KHR_materials_anisotropy + let anisotropyStrength: Float = anisotropy?.anisotropyStrength ?? 0 + let anisotropyRotation: Float = anisotropy?.anisotropyRotation ?? 0 let textures: [Int?] = [ pbr?.baseColorTexture?.index, pbr?.metallicRoughnessTexture?.index, @@ -101,9 +107,12 @@ extension NativeGLTFLoader { let scalarsAreValid = normalScale.isFinite && cutoff.isFinite && cutoff >= 0 && strength.isFinite && strength >= 0 let coatIsValid = clearcoatFactor.isFinite && clearcoatFactor >= 0 && clearcoatFactor <= 1 && clearcoatRoughness.isFinite && clearcoatRoughness >= 0 && clearcoatRoughness <= 1 + let sheenIsValid = sheenColor.count == 3 && sheenColor.allSatisfy { $0.isFinite && $0 >= 0 && $0 <= 1 } + && sheenRoughness.isFinite && sheenRoughness >= 0 && sheenRoughness <= 1 + let anisotropyIsValid = anisotropyStrength.isFinite && anisotropyStrength >= 0 && anisotropyStrength <= 1 && anisotropyRotation.isFinite let specularIsValid = ior.isFinite && ior >= 1 && ior <= 100 && specularFactor.isFinite && specularFactor >= 0 && specularColor.count == 3 && specularColor.allSatisfy { $0.isFinite && $0 >= 0 } - guard factorsAreValid, scalarsAreValid, specularIsValid, coatIsValid, texturesExist, transformsAreValid, + guard factorsAreValid, scalarsAreValid, specularIsValid, coatIsValid, sheenIsValid, anisotropyIsValid, texturesExist, transformsAreValid, coordinates.allSatisfy({ $0 == 0 || $0 == 1 }), let alpha = GLTFImportResult.AlphaMode(rawValue: material.alphaMode ?? "OPAQUE") else { throw GLTFError.invalidMaterial(index) } @@ -130,7 +139,11 @@ extension NativeGLTFLoader { specularFactor: specularFactor, specularColorFactor: Vector3(specularColor[0], specularColor[1], specularColor[2]), clearcoatFactor: clearcoatFactor, - clearcoatRoughnessFactor: clearcoatRoughness + clearcoatRoughnessFactor: clearcoatRoughness, + sheenColorFactor: Vector3(sheenColor[0], sheenColor[1], sheenColor[2]), + sheenRoughnessFactor: sheenRoughness, + anisotropyStrength: anisotropyStrength, + anisotropyRotation: anisotropyRotation ) } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift index ee0e622d3..3fded40b6 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift @@ -139,7 +139,20 @@ extension NativeGLTFLoader { let clearcoatRoughnessTexture: TextureReference? let clearcoatNormalTexture: TextureReference? } + struct Sheen: Codable { + let sheenColorFactor: [Float]? + let sheenColorTexture: TextureReference? + let sheenRoughnessFactor: Float? + let sheenRoughnessTexture: TextureReference? + } + struct Anisotropy: Codable { + let anisotropyStrength: Float? + let anisotropyRotation: Float? + let anisotropyTexture: TextureReference? + } let KHR_materials_emissive_strength: EmissiveStrength? + let KHR_materials_sheen: Sheen? + let KHR_materials_anisotropy: Anisotropy? let KHR_materials_clearcoat: Clearcoat? let KHR_materials_ior: IOR? let KHR_materials_specular: Specular? diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 29d34bb35..fde0632ac 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -26,6 +26,8 @@ public struct NativeGLTFLoader: GLTFLoader { "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_clearcoat", + "KHR_materials_sheen", + "KHR_materials_anisotropy", ] /// Optional content the importer cannot honor: unimplemented extensions the file lists as used, and the parts @@ -43,6 +45,12 @@ public struct NativeGLTFLoader: GLTFLoader { coat.clearcoatTexture != nil || coat.clearcoatRoughnessTexture != nil || coat.clearcoatNormalTexture != nil { result.append("Material \(index) uses a KHR_materials_clearcoat texture; only its factors were applied.") } + if let sheen = material.extensions?.KHR_materials_sheen, sheen.sheenColorTexture != nil || sheen.sheenRoughnessTexture != nil { + result.append("Material \(index) uses a KHR_materials_sheen texture; only its factors were applied.") + } + if let anisotropy = material.extensions?.KHR_materials_anisotropy, anisotropy.anisotropyTexture != nil { + result.append("Material \(index) uses a KHR_materials_anisotropy texture; only its factors were applied.") + } } return result } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index 3d824516f..ca12f9da2 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -145,6 +145,8 @@ layout (binding = 15) uniform PBR3DUniform { vec4 u_TextureTransforms[10]; vec4 u_Dielectric; // rgb: reflectance of non-metals at normal incidence (0.04 for the default IOR of 1.5) vec4 u_Coat; // x clearcoat strength, y clearcoat roughness (KHR_materials_clearcoat) + vec4 u_Sheen; // rgb sheen color, w sheen roughness (KHR_materials_sheen) + vec4 u_Anisotropy; // x strength, y rotation in radians (KHR_materials_anisotropy) }; vec2 applyTextureTransform(vec2 uv, int slot) { @@ -260,6 +262,25 @@ float coatTransmission(float normalView) { return 1.0 - g_Clearcoat.x * fresnelSchlick(normalView, vec3(0.04)).x; } +// Sheen layer (cloth): rgb color, w roughness. A black color turns it off. +vec4 g_Sheen = vec4(0.0); +// Average share of light the sheen lobe takes from the base layer. The glTF sheen albedo lookup is replaced by this constant. +const float SHEEN_ALBEDO = 0.4; + +bool hasSheen() { + return max(g_Sheen.r, max(g_Sheen.g, g_Sheen.b)) > 0.0; +} + +// Share of the base layer's light that survives the sheen. +float sheenTransmission() { + return 1.0 - max(g_Sheen.r, max(g_Sheen.g, g_Sheen.b)) * SHEEN_ALBEDO; +} + +// Anisotropy: strength, and the view-space tangent and bitangent of the stretched highlight. +float g_Anisotropy = 0.0; +vec3 g_AnisotropyT = vec3(1.0, 0.0, 0.0); +vec3 g_AnisotropyB = vec3(0.0, 1.0, 0.0); + // UV shift from parallax occlusion mapping, applied to every material texture that uses the height map's UV set. vec2 g_ParallaxDelta = vec2(0.0); @@ -341,11 +362,23 @@ vec3 environmentLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float vec3 fresnel = f0 + (max(vec3(1.0 - roughness), f0) - f0) * pow(1.0 - ndotv, 5.0); vec3 diffuseWeight = (vec3(1.0) - fresnel) * (1.0 - metallic); vec3 irradiance = texture(sampler2D(u_Irradiance, u_IBLSampler), environmentUV(worldNormal)).rgb; - vec3 reflected = samplePrefiltered(reflect(-worldView, worldNormal), roughness); + vec3 reflectionNormal = worldNormal; + if (g_Anisotropy > 0.0) { + // Bend the reflection toward the stretched direction so the environment smears along it (Filament). + vec3 anisotropicTangent = cross(g_AnisotropyB, viewDirection); + vec3 anisotropicNormal = cross(anisotropicTangent, g_AnisotropyB); + float bend = g_Anisotropy * clamp(5.0 * roughness, 0.0, 1.0); + reflectionNormal = normalize(mat3(u_EnvironmentInverseView) * normalize(mix(normal, anisotropicNormal, bend))); + } + vec3 reflected = samplePrefiltered(reflect(-worldView, reflectionNormal), roughness); vec2 lutSize = vec2(textureSize(sampler2D(u_BRDF, u_IBLSampler), 0)); vec2 lutUV = clamp(vec2(ndotv, roughness), 0.5 / lutSize, vec2(1.0) - 0.5 / lutSize); vec2 lut = texture(sampler2D(u_BRDF, u_IBLSampler), lutUV).rg; vec3 result = (diffuseWeight * irradiance * baseColor / PI + reflected * (f0 * lut.x + lut.y)) * u_IBLParameters.y; + if (hasSheen()) { + // Sheen under image-based lighting is approximated as a diffuse lobe with the average sheen albedo. + result = result * sheenTransmission() + g_Sheen.rgb * SHEEN_ALBEDO * irradiance / PI * u_IBLParameters.y; + } if (g_Clearcoat.x > 0.0) { // The coat reflects the environment along the geometric normal and dims the base layer beneath it. vec3 coatWorldNormal = normalize(mat3(u_EnvironmentInverseView) * g_CoatNormal); @@ -450,6 +483,18 @@ vec4 clearcoatDirect(vec3 viewDirection, vec3 lightDirection, vec3 radiance) { return vec4(vec3(specular) * max(radiance, vec3(0.0)) * nl, transmission); } +// Charlie sheen distribution (Estevez and Kulla) and the Neubelt visibility term, as in the glTF sheen specification. +float sheenDistribution(float roughness, float normalHalfway) { + float alpha = roughness * roughness; + float inverseAlpha = 1.0 / alpha; + float sine2 = max(1.0 - normalHalfway * normalHalfway, 0.0078125); + return (2.0 + inverseAlpha) * pow(sine2, inverseAlpha * 0.5) / (2.0 * PI); +} + +float sheenVisibility(float normalLight, float normalView) { + return clamp(1.0 / (4.0 * (normalLight + normalView - normalLight * normalView)), 0.0, 1.0); +} + vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 baseColor, float metallic, float roughness, vec3 radiance) { float nl = max(dot(normal, lightDirection), 0.0), nv = max(dot(normal, viewDirection), 0.0); vec3 base = vec3(0.0); @@ -457,10 +502,28 @@ vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 b vec3 halfway = normalize(viewDirection + lightDirection); vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); - float distribution = distributionGGX(normal, halfway, roughness); - float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); - vec3 specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); + vec3 specular; + if (g_Anisotropy > 0.0) { + // Anisotropic GGX: roughness stretches along the tangent, so the highlight streaks along the bitangent. + float normalHalfway = max(dot(normal, halfway), 0.0); + float alpha = roughness * roughness; + float alphaT = mix(alpha, 1.0, g_Anisotropy * g_Anisotropy), alphaB = alpha; + float tangentHalfway = dot(g_AnisotropyT, halfway), bitangentHalfway = dot(g_AnisotropyB, halfway); + float denominator = tangentHalfway * tangentHalfway / (alphaT * alphaT) + bitangentHalfway * bitangentHalfway / (alphaB * alphaB) + normalHalfway * normalHalfway; + float distribution = 1.0 / max(PI * alphaT * alphaB * denominator * denominator, 0.000001); + float lambdaView = nl * length(vec3(alphaT * dot(g_AnisotropyT, viewDirection), alphaB * dot(g_AnisotropyB, viewDirection), nv)); + float lambdaLight = nv * length(vec3(alphaT * dot(g_AnisotropyT, lightDirection), alphaB * dot(g_AnisotropyB, lightDirection), nl)); + specular = fresnel * distribution * 0.5 / max(lambdaView + lambdaLight, 0.00001); + } else { + float distribution = distributionGGX(normal, halfway, roughness); + float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); + specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); + } base = ((vec3(1.0) - fresnel) * (1.0 - metallic) * baseColor / PI + specular) * max(radiance, vec3(0.0)) * nl; + if (hasSheen()) { + vec3 sheen = g_Sheen.rgb * sheenDistribution(g_Sheen.w, max(dot(normal, halfway), 0.0)) * sheenVisibility(nl, nv); + base = base * sheenTransmission() + sheen * max(radiance, vec3(0.0)) * nl; + } } if (g_Clearcoat.x <= 0.0) { return base; } vec4 coat = clearcoatDirect(viewDirection, lightDirection, radiance); @@ -600,6 +663,16 @@ void flat3d_fragment() vec3 normal = materialNormal(); if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { normal = -normal; } + g_Sheen = vec4(max(u_Sheen.rgb, vec3(0.0)), clamp(u_Sheen.w, 0.07, 1.0)); + g_Anisotropy = clamp(u_Anisotropy.x, 0.0, 1.0); + if (g_Anisotropy > 0.0) { + bool authored = Input.TextureFlags.w > 0.5; + vec3 tangent = authored ? Input.ViewTangent.xyz : cotangentFrame(normalize(Input.ViewNormal), Input.ViewPosition, materialUV(u_UVSets.x, 0))[0]; + tangent = normalize(tangent - normal * dot(normal, tangent)); + vec3 bitangent = normalize(cross(normal, tangent)) * (authored ? Input.ViewTangent.w : 1.0); + g_AnisotropyT = normalize(cos(u_Anisotropy.y) * tangent + sin(u_Anisotropy.y) * bitangent); + g_AnisotropyB = normalize(cross(normal, g_AnisotropyT)); + } g_Clearcoat = vec2(u_Coat.x, clamp(u_Coat.y, 0.04, 1.0)); g_CoatNormal = normalize(Input.ViewNormal); if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { g_CoatNormal = -g_CoatNormal; } @@ -640,20 +713,11 @@ void flat3d_fragment() float normalLight = max(dot(normal, lightDirection), 0.0); float normalView = max(dot(normal, viewDirection), 0.0); - vec3 reflectanceAtNormal = mix(u_Dielectric.rgb, baseColor.rgb, metallic); - vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), reflectanceAtNormal); - float distribution = distributionGGX(normal, halfway, roughness); - float geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); - vec3 specular = distribution * geometry * fresnel / max(4.0 * normalView * normalLight, 0.0001); - vec3 diffuseWeight = (vec3(1.0) - fresnel) * (1.0 - metallic); vec3 radiance = max(u_LightRadianceAmbient.rgb, vec3(0.0)) * max(u_LightDirectionIntensity.w, 0.0); - // The sun term is multiplied by n.l, so surfaces facing away from it skip the cascade lookups. + // The sun term is multiplied by n.l, so surfaces facing away from it skip the cascade lookups. The clearcoat + // follows the geometric normal and can still be lit there. float shadowVisibility = (normalLight > 0.0 || g_Clearcoat.x > 0.0) ? directionalShadow(normal, lightDirection) : 1.0; - vec3 directLighting = (diffuseWeight * baseColor.rgb / PI + specular) * radiance * normalLight * shadowVisibility; - if (g_Clearcoat.x > 0.0) { - vec4 coat = clearcoatDirect(viewDirection, lightDirection, radiance); - directLighting = directLighting * coat.a + coat.rgb * shadowVisibility; - } + vec3 directLighting = evaluateDirect(normal, viewDirection, lightDirection, baseColor.rgb, metallic, roughness, radiance) * shadowVisibility; directLighting += additionalLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); vec3 ambient = baseColor.rgb * (1.0 - metallic) * max(u_LightRadianceAmbient.w, 0.0); if (u_IBLParameters.x > 0.5) { ambient = environmentLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); } diff --git a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift index 5a5379c15..93aadea24 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift @@ -39,6 +39,10 @@ public struct PBR3DUniform: Sendable, Equatable { public let dielectric: Vector4 /// X is the clearcoat strength and y its roughness. public let coat: Vector4 + /// RGB is the sheen color and w its roughness. + public let sheen: Vector4 + /// X is the anisotropy strength and y its rotation in radians. + public let anisotropy: Vector4 public init(material: PBRMaterial?, descriptor _: VertexDescriptor) { func coordinate(_ slot: Int) -> Int { @@ -74,6 +78,19 @@ public struct PBR3DUniform: Sendable, Equatable { 0, 0 ) + let sheenColor = material?.sheenColorFactor ?? .zero + sheen = Vector4( + finiteRenderValue(sheenColor.x, fallback: 0, range: 0...1), + finiteRenderValue(sheenColor.y, fallback: 0, range: 0...1), + finiteRenderValue(sheenColor.z, fallback: 0, range: 0...1), + finiteRenderValue(material?.sheenRoughnessFactor ?? 0, fallback: 0, range: 0...1) + ) + anisotropy = Vector4( + finiteRenderValue(material?.anisotropyStrength ?? 0, fallback: 0, range: 0...1), + finiteRenderValue(material?.anisotropyRotation ?? 0, fallback: 0, range: -100...100), + 0, + 0 + ) } /// Reflectance at normal incidence of the dielectric layer: ((ior - 1) / (ior + 1))^2 tinted and scaled by the @@ -98,7 +115,7 @@ public struct PBR3DUniform: Sendable, Equatable { bits(emissiveFactor), bits(properties), bits(flags), bits(coordinates), bits(emission), bits(parallax), bits(transform0.matrix), bits(transform0.offset), bits(transform1.matrix), bits(transform1.offset), bits(transform2.matrix), bits(transform2.offset), bits(transform3.matrix), bits(transform3.offset), - bits(transform4.matrix), bits(transform4.offset), bits(dielectric), bits(coat), + bits(transform4.matrix), bits(transform4.offset), bits(dielectric), bits(coat), bits(sheen), bits(anisotropy), ] } } diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index 413b77002..714d1e468 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -74,6 +74,14 @@ public class PBRMaterial: Material, @unchecked Sendable { public var clearcoatFactor: Float = 0 { didSet { uniformRevision &+= 1 } } /// Roughness of the clearcoat layer, independent of the base layer's roughness. public var clearcoatRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1 } } + /// Color of a soft fabric sheen over the surface (`KHR_materials_sheen`); black turns the layer off. + public var sheenColorFactor: Vector3 = .zero { didSet { uniformRevision &+= 1 } } + /// Roughness of the sheen layer. + public var sheenRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1 } } + /// How much highlights stretch (`KHR_materials_anisotropy`, 0 to 1); 0 keeps them round. + public var anisotropyStrength: Float = 0 { didSet { uniformRevision &+= 1 } } + /// Rotation of the stretch direction from the surface tangent, in radians. + public var anisotropyRotation: Float = 0 { didSet { uniformRevision &+= 1 } } /// Changes whenever parameters in the shared PBR uniform snapshot are edited. @_spi(Internal) public private(set) var uniformRevision: UInt64 = 0 diff --git a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift index 9b6bae273..250454c4d 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift @@ -67,6 +67,10 @@ extension ModelAsset3D { material.specularColorFactor = source.specularColorFactor material.clearcoatFactor = source.clearcoatFactor material.clearcoatRoughnessFactor = source.clearcoatRoughnessFactor + material.sheenColorFactor = source.sheenColorFactor + material.sheenRoughnessFactor = source.sheenRoughnessFactor + material.anisotropyStrength = source.anisotropyStrength + material.anisotropyRotation = source.anisotropyRotation switch source.alphaMode { case .opaque: material.alphaMode = .opaque case .mask: material.alphaMode = .mask diff --git a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift index e74052930..274eb40cf 100644 --- a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift +++ b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift @@ -111,17 +111,52 @@ struct GLTFMaterialExtensionTests { #expect(result.warnings.count == 1 && result.warnings[0].contains("KHR_materials_clearcoat") && result.warnings[0].contains("Material 0")) } + @Test + func importsSheenAndAnisotropy() throws { + let material = try #require(load(material: ["extensions": [ + "KHR_materials_sheen": ["sheenColorFactor": [0.9, 0.5, 0.2], "sheenRoughnessFactor": 0.4], + "KHR_materials_anisotropy": ["anisotropyStrength": 0.6, "anisotropyRotation": 1.2], + ]], used: ["KHR_materials_sheen", "KHR_materials_anisotropy"]).materials.first) + #expect(material.sheenColorFactor == Vector3(0.9, 0.5, 0.2) && material.sheenRoughnessFactor == 0.4) + #expect(material.anisotropyStrength == 0.6 && material.anisotropyRotation == 1.2) + let plain = try #require(load(material: [:]).materials.first) + #expect(plain.sheenColorFactor == .zero && plain.sheenRoughnessFactor == 0) + #expect(plain.anisotropyStrength == 0 && plain.anisotropyRotation == 0) + } + + @Test(arguments: [ + #"{"KHR_materials_sheen":{"sheenColorFactor":[2,0,0]}}"#, + #"{"KHR_materials_sheen":{"sheenColorFactor":[1,1]}}"#, + #"{"KHR_materials_sheen":{"sheenRoughnessFactor":-1}}"#, + #"{"KHR_materials_anisotropy":{"anisotropyStrength":2}}"#, + #"{"KHR_materials_anisotropy":{"anisotropyStrength":-0.5}}"#, + ]) + func rejectsOutOfRangeSheenAndAnisotropy(json: String) throws { + let extensions = try object(json) + #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { try load(material: ["extensions": extensions]) } + } + + @Test + func sheenAndAnisotropyTexturesAreReportedAsSkipped() throws { + let result = try load(material: ["extensions": [ + "KHR_materials_sheen": ["sheenColorTexture": ["index": 0]], + "KHR_materials_anisotropy": ["anisotropyTexture": ["index": 0]], + ]], used: ["KHR_materials_sheen", "KHR_materials_anisotropy"]) + #expect(result.warnings.count == 2) + #expect(result.warnings.contains { $0.contains("KHR_materials_sheen") } && result.warnings.contains { $0.contains("KHR_materials_anisotropy") }) + } + @Test func unsupportedUsedExtensionsLoadWithAWarning() throws { - let result = try load(material: [:], used: ["KHR_materials_sheen", "KHR_texture_transform"]) + let result = try load(material: [:], used: ["KHR_materials_transmission", "KHR_texture_transform"]) #expect(result.warnings.count == 1) - #expect(result.warnings.first?.contains("KHR_materials_sheen") == true) + #expect(result.warnings.first?.contains("KHR_materials_transmission") == true) } @Test func unsupportedRequiredExtensionsStillFail() { - #expect(throws: NativeGLTFLoader.GLTFError.unsupportedRequiredExtension("KHR_materials_sheen")) { - try load(material: [:], used: ["KHR_materials_sheen"], required: ["KHR_materials_sheen"]) + #expect(throws: NativeGLTFLoader.GLTFError.unsupportedRequiredExtension("KHR_materials_transmission")) { + try load(material: [:], used: ["KHR_materials_transmission"], required: ["KHR_materials_transmission"]) } } @@ -134,7 +169,7 @@ struct GLTFMaterialExtensionTests { @Test func theSupportedListNamesEveryImplementedExtension() { let supported = NativeGLTFLoader.supportedExtensions - for name in ["KHR_texture_transform", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_clearcoat", "KHR_materials_emissive_strength", "KHR_mesh_quantization"] { + for name in ["KHR_texture_transform", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_clearcoat", "KHR_materials_sheen", "KHR_materials_anisotropy", "KHR_materials_emissive_strength", "KHR_mesh_quantization"] { #expect(supported.contains(name)) } } diff --git a/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift b/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift index a1f686d10..cda38991a 100644 --- a/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift +++ b/Tests/AdaEngineTests/GLTFMaterialExtensionMappingTests.swift @@ -24,7 +24,11 @@ struct GLTFMaterialExtensionMappingTests { specularFactor: 0.6, specularColorFactor: Vector3(1, 0.8, 0.6), clearcoatFactor: 0.7, - clearcoatRoughnessFactor: 0.15 + clearcoatRoughnessFactor: 0.15, + sheenColorFactor: Vector3(0.5, 0.4, 0.3), + sheenRoughnessFactor: 0.35, + anisotropyStrength: 0.8, + anisotropyRotation: 0.25 ) let result = GLTFImportResult(nodes: [], meshes: [], materials: [source], textures: [], images: [], scenes: [], defaultScene: nil) let material = try #require(ModelAsset3D.makeMaterials(result).first as? PBRMaterial) @@ -34,5 +38,7 @@ struct GLTFMaterialExtensionMappingTests { #expect(material.ior == 1.33 && material.specularFactor == 0.6) #expect(material.specularColorFactor == Vector3(1, 0.8, 0.6)) #expect(material.clearcoatFactor == 0.7 && material.clearcoatRoughnessFactor == 0.15) + #expect(material.sheenColorFactor == Vector3(0.5, 0.4, 0.3) && material.sheenRoughnessFactor == 0.35) + #expect(material.anisotropyStrength == 0.8 && material.anisotropyRotation == 0.25) } } diff --git a/Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-anisotropy.png b/Tests/AdaEngineTests/GoldenFrames/metal/pbr-sphere-anisotropy.png new file mode 100644 index 0000000000000000000000000000000000000000..403857bf8a72e31eed75ca9026d640e7ab0716d7 GIT binary patch literal 95800 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 z%0D%7U(k0PTp`NqT+YIHGM5S>9RU?kC0xxptOT;Z~^4xqA6xK(2|m0ngM+ znA>z3HFwq8dW|I8Q$!LoqRN>gT9&eY2C1*>R+@pv={6y|sciI?sDgR!zFm0WoHFtQ}@(0?6_d3IKBw)qN`**L<5 z(OgE(#-?gmLt@58jcT&g=I%%pP|1omY0OF%3W%7H+U!%k$#Db&Q6Cpo!{!`wkIiZw zVaN*|?NCLMO(h{<6YxUIzC3bUpoeG9wE?vGUEYdbaHc$=e6VKwMU{YqK`4T zS_`>w8&x~C*)%SySmVzHOmi7nT9gh3_ogP0ft)cUE1PNn!QmZc)OWHM<7z%5c1q6qF_FN4pbA9*-DNu+3j+?s+d5%S~WW1*gUp+5Yq}~9hnd;COr{WYtM9Wdb7XtG0mni7Dib7PVgW=YL{Zhqr^M8~wt zo~S4MhiO6DgUIGKvzvb~?he@kI7n|lmz^ZkT1A~#l zHJJT=!pOm~`w04w4e@9Kx%@%>J}XAJ=I3Z`Ql;eFN(mFiSlDQ7Je`AtNFTfR5Hj9_ z2$kbzb|)*`tY~QFz~$U z(>%hDx9kpf{b|&Xcyzr3c;glW2gaVQ#}^Bf_JA*dwOqTvYh)MT$K+~_y=6<<o*8bszz*XDRaIeu2}1l9yx1wC+feqc*3EXisAP0oh>FN;_>#TIHyYG&b<#{v`r zH)#zz7`w4}gU0j{`o=8QQyqM#f~Et`VeEPSpGIhKt}v2)+tvz*Pm6rU)PXn-^h zyaQ7v?A?u}Q6t&d;nvA1UP)k;c#+hq8YYZHr zE8igUf=z?`15++y#ZAx#;i?Onj^(i@S(DfWxA_!MLRcx5MAO(w4NFKVU?P^l4ztd* zj_63uuoyabPCYnOr%p>c8}|q9j@TVwwH_U41UGOzX=h&je6{==+5nDD`d7cEH?;yh zefO`ZMcI>oKf|?NFPq+^$Q8Gq=m}+CH8SC;W?4&X1glT?kuS4gE5@B-Ztj?Hgw`X- zmV>NjN=;nk7`Bk%W{C(S(fFW(& l_IPL42vMu&Svo$vNGjd@Jvx%*gk{S*Ob|) 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 eXFgwC=l=n)Htp!FcX1d10000 Void) async throws -> (scene: Render3DGPUTests.DebugViews.Scene, material: PBRMaterial) { + private static func makeScene(polesSideways: Bool = false, _ configure: (PBRMaterial) -> Void) async throws -> (scene: Render3DGPUTests.DebugViews.Scene, material: PBRMaterial) { let scene = try await Render3DDebugViewGPUTests.makeScene() let material = PBRMaterial() material.baseColorFactor = [0.5, 0.5, 0.5, 1] @@ -110,7 +127,7 @@ extension Render3DGPUTests { configure(material) scene.app.main.spawn("Probe") { Mesh3DComponent(mesh: Mesh.generateSphere(radius: 0.5, renderDevice: scene.device), materials: [material]) - Transform(position: [0, 0.5, -2.2]) + Transform(rotation: polesSideways ? Quat(axis: Vector3(0, 0, 1), angle: .pi / 2) : .identity, position: [0, 0.5, -2.2]) } return (scene, material) } @@ -216,6 +233,56 @@ extension Render3DGPUTests { #expect(zip(gains, gains.dropFirst()).allSatisfy { $0 < $1 }, "The coat's highlight must grow with its strength: \(gains)") } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func sheenAddsSoftLightThatGrowsWithItsColorAndTurnsOff() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.9 } + let baseline = try await scene.render(.none) + material.sheenRoughnessFactor = 0.4 + var gains: [Float] = [] + for strength: Float in [0.3, 0.7, 1] { + material.sheenColorFactor = Vector3(strength, strength, strength) + gains.append(Self.largestGain(try await scene.render(.none), over: baseline)) + } + #expect(gains[0] > 0.05, "Sheen must add light (gain \(gains[0])).") + #expect(zip(gains, gains.dropFirst()).allSatisfy { $0 < $1 }, "Sheen must grow with its color: \(gains)") + material.sheenColorFactor = .zero + #expect(Self.matches(baseline, try await scene.render(.none)), "Turning sheen off must restore the frame.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func sheenColorTintsTheAddedLight() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.9; $0.sheenRoughnessFactor = 0.4 } + material.sheenColorFactor = Vector3(1, 0, 0) + let red = try await scene.render(.none) + material.sheenColorFactor = Vector3(0, 0, 1) + let blue = try await scene.render(.none) + var redExcess: Float = 0 + for y in stride(from: 130, to: 350, by: 2) { + for x in stride(from: 170, to: 470, by: 2) { + let a = red.getPixel(x: x, y: y), b = blue.getPixel(x: x, y: y) + redExcess += (a.red - a.blue) - (b.red - b.blue) + } + } + #expect(redExcess > 0, "A red sheen must leave the surface redder than a blue one (\(redExcess)).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func anisotropyStretchesTheHighlightAndRotationTurnsIt() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.35 } + let round = try await scene.render(.none) + material.anisotropyStrength = 0.8 + let stretched = try await scene.render(.none) + material.anisotropyRotation = .pi / 2 + let turned = try await scene.render(.none) + #expect(!Self.matches(round, stretched), "Anisotropy must change the highlight.") + #expect(!Self.matches(stretched, turned), "Rotating the stretch direction must change the highlight.") + material.anisotropyRotation = .pi + #expect(Self.matches(stretched, try await scene.render(.none)), "Half a turn flips the direction and must look the same.") + material.anisotropyStrength = 0 + material.anisotropyRotation = 0 + #expect(Self.matches(round, try await scene.render(.none)), "Zero strength must restore the round highlight.") + } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) func clearcoatMatchesItsGoldenFrame() async throws { let (scene, _) = try await Self.makeScene { @@ -235,6 +302,37 @@ extension Render3DGPUTests { } } + private static func expectGolden(_ image: Image, named name: String) throws { + switch try GoldenFrame.check(image, name: name) { + case .recorded, .matched: + break + case .missingReference(let actual): + Issue.record("No golden frame for \(name); record with ADAENGINE_GOLDEN_RECORD=1. Actual: \(actual.path)") + case let .mismatched(comparison, actual, difference): + Issue.record("\(name) differs in \(comparison.differingPixels) pixels. Actual: \(actual.path), difference: \(difference.path)") + } + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func sheenAndAnisotropyMatchTheirGoldenFrames() async throws { + let (velvet, _) = try await Self.makeScene { + $0.baseColorFactor = [0.25, 0.02, 0.05, 1] + $0.metallicFactor = 0 + $0.roughnessFactor = 0.85 + $0.sheenColorFactor = Vector3(0.9, 0.55, 0.65) + $0.sheenRoughnessFactor = 0.45 + } + try Self.expectGolden(try await velvet.render(.none), named: "pbr-sphere-sheen") + // The sphere's UV poles degenerate, which shows in a derivative-based tangent frame, so keep them at the sides. + let (brushed, _) = try await Self.makeScene(polesSideways: true) { + $0.baseColorFactor = [0.8, 0.8, 0.82, 1] + $0.metallicFactor = 1 + $0.roughnessFactor = 0.35 + $0.anisotropyStrength = 0.85 + } + try Self.expectGolden(try await brushed.render(.none), named: "pbr-sphere-anisotropy") + } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) func defaultExtensionValuesChangeNothing() async throws { let (scene, material) = try await Self.makeScene { _ in } From 2813de33b8403b84fb09dfc5fc007c31006dfbca Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 12:06:44 +0300 Subject: [PATCH 04/11] Test: allow the measured temporal drift in the LUT checks The temporal resolve is not exactly repeatable at a bright highlight: two untouched renders of the probe at the first sphere's highlight differ by one step in some runs and three in others, and the identity-LUT check required at most three steps from the first render. It failed in about half of the full GPU runs and never in isolation. The identity and inverted-LUT checks now allow the drift measured between the two untouched renders on top of their tolerance on the temporal path, so they test the LUT and not the noise floor. Four consecutive full runs pass. Co-Authored-By: Claude Sonnet 5.5 --- .../Render3DColorGradingTests.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Tests/AdaEngineTests/Render3DColorGradingTests.swift b/Tests/AdaEngineTests/Render3DColorGradingTests.swift index 3b9c18009..6fbc544e3 100644 --- a/Tests/AdaEngineTests/Render3DColorGradingTests.swift +++ b/Tests/AdaEngineTests/Render3DColorGradingTests.swift @@ -205,12 +205,21 @@ extension Render3DGPUTests { ) } + // The temporal resolve is not exactly repeatable at a bright highlight: two untouched renders already differ by a + // few steps there, and by a different amount from run to run. Its checks allow that measured drift on top of + // the LUT's own tolerance, so they test the LUT and not the noise floor. + func noise(_ x: Int, _ y: Int, _ channel: Int) -> Float { + guard path == .temporal else { return 0 } + return Self.steps(Self.channels(plain.getPixel(x: x, y: y))[channel], Self.channels(repeated.getPixel(x: x, y: y))[channel]) + } + let identity = AssetHandle(Self.lutTexture()) try scene.configure { $0.postProcess.colorGrading = ColorGrading3D(lut: identity) } let same = try await scene.render(.none, frames: frames) for (x, y) in Self.probes { - for (left, right) in zip(Self.channels(same.getPixel(x: x, y: y)), Self.channels(plain.getPixel(x: x, y: y))) { - #expect(Self.steps(left, right) <= 3, "\(path): identity LUT changed (\(x), \(y)) by \(Self.steps(left, right)) steps.") + for (channel, (left, right)) in zip(Self.channels(same.getPixel(x: x, y: y)), Self.channels(plain.getPixel(x: x, y: y))).enumerated() { + let allowed = 3 + noise(x, y, channel) + #expect(Self.steps(left, right) <= allowed, "\(path): identity LUT changed (\(x), \(y)) channel \(channel) by \(Self.steps(left, right)) steps (allowed \(allowed)).") } } @@ -222,7 +231,7 @@ extension Render3DGPUTests { for (x, y) in Self.probes { let base = Self.channels(plain.getPixel(x: x, y: y)) for (index, value) in Self.channels(inverted.getPixel(x: x, y: y)).enumerated() { - #expect(Self.steps(value, 1 - base[index]) <= 5, "\(path): inverted LUT at (\(x), \(y)) channel \(index): \(value * 255) vs \((1 - base[index]) * 255).") + #expect(Self.steps(value, 1 - base[index]) <= 5 + noise(x, y, index), "\(path): inverted LUT at (\(x), \(y)) channel \(index): \(value * 255) vs \((1 - base[index]) * 255).") } // Halfway between a color and its inverse is mid gray whatever the color. for value in Self.channels(halfway.getPixel(x: x, y: y)) { From 1d02b6b05703e62dbfff7127c1f248dd4ecc9a73 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 12:06:44 +0300 Subject: [PATCH 05/11] 3D: compile the main shader per shading layer so unused layers cost nothing PBRMaterial.shaderFeatures (clearcoat, sheen, anisotropy) joins the PBR pipeline key, and Flat3DPipeline takes a fragment shader variant that contains only those layers. The layer predicates in flat3d.glsl are constant false unless ADA_CLEARCOAT, ADA_SHEEN or ADA_ANISOTROPY is defined, so the plain shader (disk cached, and the only one on the web) has no layer code. Variants are compiled from the file text on first use because the on-disk shader cache is keyed by file and stage. Main-pass geometry on the benchmark scene goes from 1.92 ms back to 1.77 ms (32 lights: 2.40 to 2.17 ms). Records the result and limits in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 25 +++++++++++++ .../Assets/Shaders/flat3d.glsl | 36 +++++++++++++++---- .../RenderPipelines3D/Flat3DPipeline.swift | 35 +++++++++++++++++- .../RenderPipelines3D/PBR3DPipeline.swift | 7 ++-- Sources/AdaRender/Materials/PBRMaterial.swift | 32 +++++++++++++++++ Sources/AdaRender/Shaders/ShaderModule.swift | 31 ++++++++++++++++ Sources/AdaScene/3D/Model3DPlugin.swift | 6 ++-- .../Render3DMaterialExtensionTests.swift | 29 +++++++++++++++ 8 files changed, 189 insertions(+), 12 deletions(-) diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 6588b0b0b..e89fda071 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -825,6 +825,31 @@ the uniform in `Render3DMaterialExtensionConstantTests`, and in `Render3DGPUTest grows with its color and tints it, turning it off restores the frame, anisotropy changes and rotates the highlight and a half turn looks the same, plus the `pbr-sphere-sheen` and `pbr-sphere-anisotropy` golden frames (Metal). +### Stage 2, fourth slice: main shader specialized by shading layer (2026-10-19) + +The third slice left layered materials costing about 8 percent of main-pass geometry even when no material used a layer. +The main shader is now specialized by material: + +- `PBRMaterial.shaderFeatures` is the set of layers a material uses (`clearcoat`, `sheen`, `anisotropy`, derived from the + factors, so editing a factor at run time changes it). `PBR3DConfiguration.features` joins the pipeline key, and + `Flat3DPipeline` takes the matching fragment shader from `PBR3DShaderVariants`. +- In `flat3d.glsl`, `hasClearcoat()`, `hasSheen()` and `hasAnisotropy()` are constant `false` unless `ADA_CLEARCOAT`, + `ADA_SHEEN` or `ADA_ANISOTROPY` is defined, so the plain shader has none of the layer code. Names that enable a layer, not + ones that remove it, so the file with no macros is the plain shader. +- `ShaderModule.createBundled(at:from:defines:)` compiles a variant from the file's text. That bypasses the on-disk shader + cache, which is keyed by file and stage (its manifest, reflection and device-compiled entries would otherwise be overwritten + by every variant), so a variant compiles in the process on first use, and about 1 second on this machine. The plain shader, + used by almost every material, keeps the disk cache. The first attempt had the macros remove the layers, which made + the common variant a runtime compile too, and the GPU test suite went from 12 s to 51 s; it was reversed. + +**Result (1080p, Apple M-class, release).** `bare` main-pass geometry 1.92 ms with the layers in every material back to 1.77 ms, +the level before any layer (32 lights: 2.40 to 2.17 ms). Layered materials pay the full layer cost only where they are drawn. + +Limits: WebAssembly builds ship precompiled WGSL and cannot specialize, so on the web there are no layers (the plain module is +used); the motion and shadow passes do not use layers and are not split. The first draw of each new layer combination compiles +a shader. Tests: `shaderFeaturesFollowTheLayersInUse`, `pipelineKeysSplitByFeaturesAndDefaultToThePlainShader`, and the existing +clearcoat, sheen and anisotropy GPU tests, which now run through the variants (turning a layer off restores the plain frame). + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index ca12f9da2..afa56ede1 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -257,6 +257,16 @@ vec3 fresnelSchlick(float cosine, vec3 reflectanceAtNormal) { vec2 g_Clearcoat = vec2(0.0); vec3 g_CoatNormal = vec3(0.0, 0.0, 1.0); +// The optional layers are compiled in only for materials that use them (ADA_CLEARCOAT, ADA_SHEEN, ADA_ANISOTROPY), which +// keeps the common path as small as it was before the layers existed. +bool hasClearcoat() { +#ifdef ADA_CLEARCOAT + return g_Clearcoat.x > 0.0; +#else + return false; +#endif +} + // Share of the base layer's light that survives the coat at this view angle. float coatTransmission(float normalView) { return 1.0 - g_Clearcoat.x * fresnelSchlick(normalView, vec3(0.04)).x; @@ -268,7 +278,11 @@ vec4 g_Sheen = vec4(0.0); const float SHEEN_ALBEDO = 0.4; bool hasSheen() { +#ifdef ADA_SHEEN return max(g_Sheen.r, max(g_Sheen.g, g_Sheen.b)) > 0.0; +#else + return false; +#endif } // Share of the base layer's light that survives the sheen. @@ -281,6 +295,14 @@ float g_Anisotropy = 0.0; vec3 g_AnisotropyT = vec3(1.0, 0.0, 0.0); vec3 g_AnisotropyB = vec3(0.0, 1.0, 0.0); +bool hasAnisotropy() { +#ifdef ADA_ANISOTROPY + return g_Anisotropy > 0.0; +#else + return false; +#endif +} + // UV shift from parallax occlusion mapping, applied to every material texture that uses the height map's UV set. vec2 g_ParallaxDelta = vec2(0.0); @@ -363,7 +385,7 @@ vec3 environmentLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float vec3 diffuseWeight = (vec3(1.0) - fresnel) * (1.0 - metallic); vec3 irradiance = texture(sampler2D(u_Irradiance, u_IBLSampler), environmentUV(worldNormal)).rgb; vec3 reflectionNormal = worldNormal; - if (g_Anisotropy > 0.0) { + if (hasAnisotropy()) { // Bend the reflection toward the stretched direction so the environment smears along it (Filament). vec3 anisotropicTangent = cross(g_AnisotropyB, viewDirection); vec3 anisotropicNormal = cross(anisotropicTangent, g_AnisotropyB); @@ -379,7 +401,7 @@ vec3 environmentLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float // Sheen under image-based lighting is approximated as a diffuse lobe with the average sheen albedo. result = result * sheenTransmission() + g_Sheen.rgb * SHEEN_ALBEDO * irradiance / PI * u_IBLParameters.y; } - if (g_Clearcoat.x > 0.0) { + if (hasClearcoat()) { // The coat reflects the environment along the geometric normal and dims the base layer beneath it. vec3 coatWorldNormal = normalize(mat3(u_EnvironmentInverseView) * g_CoatNormal); float coatNormalView = max(dot(g_CoatNormal, viewDirection), 0.0); @@ -503,7 +525,7 @@ vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 b vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); vec3 specular; - if (g_Anisotropy > 0.0) { + if (hasAnisotropy()) { // Anisotropic GGX: roughness stretches along the tangent, so the highlight streaks along the bitangent. float normalHalfway = max(dot(normal, halfway), 0.0); float alpha = roughness * roughness; @@ -525,7 +547,7 @@ vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 b base = base * sheenTransmission() + sheen * max(radiance, vec3(0.0)) * nl; } } - if (g_Clearcoat.x <= 0.0) { return base; } + if (!hasClearcoat()) { return base; } vec4 coat = clearcoatDirect(viewDirection, lightDirection, radiance); return base * coat.a + coat.rgb; } @@ -665,7 +687,7 @@ void flat3d_fragment() if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { normal = -normal; } g_Sheen = vec4(max(u_Sheen.rgb, vec3(0.0)), clamp(u_Sheen.w, 0.07, 1.0)); g_Anisotropy = clamp(u_Anisotropy.x, 0.0, 1.0); - if (g_Anisotropy > 0.0) { + if (hasAnisotropy()) { bool authored = Input.TextureFlags.w > 0.5; vec3 tangent = authored ? Input.ViewTangent.xyz : cotangentFrame(normalize(Input.ViewNormal), Input.ViewPosition, materialUV(u_UVSets.x, 0))[0]; tangent = normalize(tangent - normal * dot(normal, tangent)); @@ -716,12 +738,12 @@ void flat3d_fragment() vec3 radiance = max(u_LightRadianceAmbient.rgb, vec3(0.0)) * max(u_LightDirectionIntensity.w, 0.0); // The sun term is multiplied by n.l, so surfaces facing away from it skip the cascade lookups. The clearcoat // follows the geometric normal and can still be lit there. - float shadowVisibility = (normalLight > 0.0 || g_Clearcoat.x > 0.0) ? directionalShadow(normal, lightDirection) : 1.0; + float shadowVisibility = (normalLight > 0.0 || hasClearcoat()) ? directionalShadow(normal, lightDirection) : 1.0; vec3 directLighting = evaluateDirect(normal, viewDirection, lightDirection, baseColor.rgb, metallic, roughness, radiance) * shadowVisibility; directLighting += additionalLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); vec3 ambient = baseColor.rgb * (1.0 - metallic) * max(u_LightRadianceAmbient.w, 0.0); if (u_IBLParameters.x > 0.5) { ambient = environmentLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); } - else if (g_Clearcoat.x > 0.0) { ambient *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); } + else if (hasClearcoat()) { ambient *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); } float occlusion = u_SurfaceFlags.y > 0.5 ? texture(sampler2D(u_OcclusionTexture, u_OcclusionSampler), materialUV(u_UVSets.w, 3)).r : 1.0; ambient *= mix(1.0, occlusion, u_SurfaceProperties.y); float emissiveVisibility = 1.0; diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift index 101d9913c..0194c2b95 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift @@ -9,6 +9,7 @@ import AdaAssets import AdaECS import AdaRender import AdaUtils +import Foundation import Math /// Per-instance data consumed by the flat 3D vertex pipeline. @@ -158,12 +159,19 @@ public struct Opaque3DInstanceBuffers: Resource { /// Pipeline configurator for a simple unlit 3D mesh pass. public struct Flat3DPipeline: RenderPipelineConfigurator { private let shader: AssetHandle + private let variants = PBR3DShaderVariants() public init() { self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/flat3d.glsl", from: .adaModule) } public func configurate(with configuration: VertexDescriptor) -> RenderPipelineDescriptor { + configurate(with: configuration, features: []) + } + + /// A pipeline whose fragment shader only contains the shading layers in `features`. + public func configurate(with configuration: VertexDescriptor, features: PBRMaterial.ShaderFeatures) -> RenderPipelineDescriptor { + let shader = features.isEmpty ? shader : variants.shader(for: features, fallback: shader) var configuration = configuration if !configuration.attributes.containsAttribute(by: MeshDescriptor.textureCoordinates.id.name) { configuration.attributes[2] = .attribute(.vector2, name: "defaultTextureCoordinate", bufferIndex: 4, offset: 0) @@ -194,7 +202,7 @@ public struct Flat3DPipeline: RenderPipelineConfigurator { var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex)) descriptor.fragment = shader.asset.getShader(for: .fragment) - descriptor.debugName = "Flat 3D Pipeline" + descriptor.debugName = features.isEmpty ? "Flat 3D Pipeline" : "Flat 3D Pipeline \(features.rawValue)" descriptor.vertexDescriptor = configuration #if os(Android) descriptor.frontFaceWinding = .clockwise @@ -213,3 +221,28 @@ public struct Flat3DPipeline: RenderPipelineConfigurator { return descriptor } } + + +/// Compiled main-pass shaders by the shading layers they include. A variant is compiled on first use and shared by every +/// pipeline that needs it; if it fails to compile, the unspecialized shader is used instead. +final class PBR3DShaderVariants: @unchecked Sendable { + private let lock = NSLock() + private var variants: [PBRMaterial.ShaderFeatures: AssetHandle] = [:] + + func shader(for features: PBRMaterial.ShaderFeatures, fallback: AssetHandle) -> AssetHandle { + lock.lock() + defer { lock.unlock() } + if let existing = variants[features] { + return existing + } + let variant: AssetHandle + do { + variant = AssetHandle(try ShaderModule.createBundled(at: "Shaders/flat3d.glsl", from: .adaModule, defines: features.enablingMacros)) + } catch { + assertionFailure("Main shader variant \(features.rawValue) failed to compile: \(error)") + variant = fallback + } + variants[features] = variant + return variant + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/PBR3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/PBR3DPipeline.swift index 8faa49b7c..3f1a6d0dd 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/PBR3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/PBR3DPipeline.swift @@ -5,8 +5,11 @@ public struct PBR3DConfiguration: Hashable, Sendable { public let vertex: VertexDescriptor public let blended: Bool public let temporalDepth: Bool + /// The shading layers the draw's material uses. The default is none, which is the plain shader. + public let features: PBRMaterial.ShaderFeatures - public init(vertex: VertexDescriptor, blended: Bool, temporalDepth: Bool = false) { + public init(vertex: VertexDescriptor, blended: Bool, temporalDepth: Bool = false, features: PBRMaterial.ShaderFeatures = []) { + self.features = features self.temporalDepth = temporalDepth self.vertex = vertex self.blended = blended @@ -19,7 +22,7 @@ public struct PBR3DPipeline: RenderPipelineConfigurator { public init() {} public func configurate(with configuration: PBR3DConfiguration) -> RenderPipelineDescriptor { - var descriptor = base.configurate(with: configuration.vertex) + var descriptor = base.configurate(with: configuration.vertex, features: configuration.features) descriptor.backfaceCulling = false if configuration.temporalDepth { descriptor.depthPixelFormat = .depth_32f } descriptor.depthStencilDescriptor?.isDepthWriteEnabled = !configuration.blended diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index 714d1e468..09a43b094 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -83,6 +83,38 @@ public class PBRMaterial: Material, @unchecked Sendable { /// Rotation of the stretch direction from the surface tangent, in radians. public var anisotropyRotation: Float = 0 { didSet { uniformRevision &+= 1 } } + /// The optional shading layers a material needs. The main shader is compiled with only the layers a material uses; + /// the empty set is the plain shader, which is also the only one that has a shader cache and a web build. + public struct ShaderFeatures: OptionSet, Hashable, Sendable { + public let rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } + + public static let clearcoat = ShaderFeatures(rawValue: 1 << 0) + public static let sheen = ShaderFeatures(rawValue: 1 << 1) + public static let anisotropy = ShaderFeatures(rawValue: 1 << 2) + + /// Every layer. + public static let all: ShaderFeatures = [.clearcoat, .sheen, .anisotropy] + + /// Preprocessor names that switch each layer in this set on. Empty for the plain shader. + public var enablingMacros: [String] { + var macros: [String] = [] + if contains(.clearcoat) { macros.append("ADA_CLEARCOAT") } + if contains(.sheen) { macros.append("ADA_SHEEN") } + if contains(.anisotropy) { macros.append("ADA_ANISOTROPY") } + return macros + } + } + + /// The layers this material currently uses, from its factor values. + public var shaderFeatures: ShaderFeatures { + var features: ShaderFeatures = [] + if clearcoatFactor > 0 { features.insert(.clearcoat) } + if max(sheenColorFactor.x, max(sheenColorFactor.y, sheenColorFactor.z)) > 0 { features.insert(.sheen) } + if anisotropyStrength > 0 { features.insert(.anisotropy) } + return features + } + /// Changes whenever parameters in the shared PBR uniform snapshot are edited. @_spi(Internal) public private(set) var uniformRevision: UInt64 = 0 diff --git a/Sources/AdaRender/Shaders/ShaderModule.swift b/Sources/AdaRender/Shaders/ShaderModule.swift index 684fcc86d..4f1552d6b 100644 --- a/Sources/AdaRender/Shaders/ShaderModule.swift +++ b/Sources/AdaRender/Shaders/ShaderModule.swift @@ -95,6 +95,37 @@ extension ShaderModule { try ShaderCompiler(from: fileUrl).compileShaderModule() } + /// Compile a bundled shader with every name in `defines` set to `1` in the fragment stage. + /// + /// The variant is compiled from the file's text rather than the file, so it bypasses the on-disk shader cache, whose + /// entries are keyed by file and stage and would mix up variants of one file. Callers should keep the result. + /// WebAssembly builds ship precompiled modules, so there the plain module is returned and `defines` are ignored. + public static func createBundled(at path: String, from bundle: Bundle, defines: [String]) throws -> ShaderModule { + guard !defines.isEmpty else { + return try loadBundled(at: path, from: bundle).asset + } + #if WASM + return try loadBundled(at: path, from: bundle).asset + #else + guard let resourceURL = bundle.resourceURL else { + throw ShaderModuleError.notFound(path) + } + let url = resourceURL.appendingPathComponent(path) + guard let data = FileSystem.current.readFile(at: url) else { + throw ShaderModuleError.notFound(path) + } + let source = try ShaderSource( + source: String(bytes: data, encoding: .utf8) ?? "", + includeSearchPaths: [.local(url.deletingLastPathComponent())] + ) + let compiler = ShaderCompiler(shaderSource: source) + for name in defines { + compiler.setMacro(name, value: "1", for: .fragment) + } + return try compiler.compileShaderModule() + #endif + } + /// Load a bundled shader module without the async asset pipeline. /// /// Browser startup still constructs render pipelines synchronously, so shader diff --git a/Sources/AdaScene/3D/Model3DPlugin.swift b/Sources/AdaScene/3D/Model3DPlugin.swift index 8cdaec541..6de7c4753 100644 --- a/Sources/AdaScene/3D/Model3DPlugin.swift +++ b/Sources/AdaScene/3D/Model3DPlugin.swift @@ -179,12 +179,14 @@ public final class Model3DDrawPass: DrawPass, @unchecked Sendable { return } + let isMotion = world.getResource(ActiveMotion3DPass.self)?.isEnabled == true let configuration = PBR3DConfiguration( vertex: part.vertexDescriptor, blended: (item.material as? PBRMaterial)?.alphaMode == .blend, - temporalDepth: view.components[RenderViewTarget.self]?.temporalUpscalingActive == true + temporalDepth: view.components[RenderViewTarget.self]?.temporalUpscalingActive == true, + // The motion shader has no shading layers, so its pipelines are not split by them. + features: isMotion ? [] : (item.material as? PBRMaterial)?.shaderFeatures ?? [] ) - let isMotion = world.getResource(ActiveMotion3DPass.self)?.isEnabled == true let pipeline: any RenderPipeline if isMotion { let pipelines = world.getRefResource(RenderPipelines.self) diff --git a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift index 76d8aaea4..94a88cd87 100644 --- a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift +++ b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift @@ -72,6 +72,35 @@ struct Render3DMaterialExtensionConstantTests { #expect(plain.bitPatterns != value.bitPatterns && value.bitPatterns != wild.bitPatterns) } + @Test func shaderFeaturesFollowTheLayersInUse() { + let material = PBRMaterial() + #expect(material.shaderFeatures.isEmpty && material.shaderFeatures.enablingMacros.isEmpty) + material.clearcoatFactor = 0.5 + #expect(material.shaderFeatures == [.clearcoat]) + material.sheenColorFactor = Vector3(0, 0.2, 0) + material.anisotropyStrength = 0.3 + #expect(material.shaderFeatures == .all) + #expect(Set(material.shaderFeatures.enablingMacros) == ["ADA_CLEARCOAT", "ADA_SHEEN", "ADA_ANISOTROPY"]) + material.clearcoatFactor = 0 + material.anisotropyStrength = 0 + #expect(material.shaderFeatures == [.sheen] && material.shaderFeatures.enablingMacros == ["ADA_SHEEN"]) + // A roughness or rotation alone does not use a layer. + let inert = PBRMaterial() + inert.clearcoatRoughnessFactor = 0.4 + inert.sheenRoughnessFactor = 0.4 + inert.anisotropyRotation = 1 + #expect(inert.shaderFeatures.isEmpty) + } + + @Test func pipelineKeysSplitByFeaturesAndDefaultToThePlainShader() { + let vertex = VertexDescriptor() + let plain = PBR3DConfiguration(vertex: vertex, blended: false) + #expect(plain.features.isEmpty) + let coated = PBR3DConfiguration(vertex: vertex, blended: false, features: .clearcoat) + #expect(plain != coated && coated == PBR3DConfiguration(vertex: vertex, blended: false, features: .clearcoat)) + #expect(Set([plain, coated, PBR3DConfiguration(vertex: vertex, blended: false, features: .all)]).count == 3) + } + @Test func nonFiniteValuesFallBackToDefaults() { let material = PBRMaterial() material.ior = .nan From a62cf472d1a179fbb16a6fff906f5c110068d86d Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 12:27:46 +0300 Subject: [PATCH 06/11] 3D: read the textures of the clearcoat, sheen, anisotropy and specular extensions Eight layer textures (clearcoat, clearcoat roughness and normal, sheen color and roughness, anisotropy direction and strength, specular strength and color) are imported with their UV set, KHR_texture_transform and scale, mapped to PBRMaterial.layerTextures, and read in flat3d.glsl, where each multiplies its factor. They bind at 32 to 39 and only in the shader variant of a material that has them (one feature bit per texture), so other materials and the plain shader are unchanged. Layer textures share the base color sampler because the main pass has no free sampler slot. The dielectric reflectance is now a per-fragment value that the specular textures scale. Adds import, mapping, feature and uniform tests and one GPU test per texture, with equivalence checks against the factor-only results. Records the design and limits in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 32 +++ Sources/AdaAssets/GLTF/GLTFMaterialData.swift | 65 +++++- .../GLTF/NativeGLTFLoader+Materials.swift | 60 ++++- .../GLTF/NativeGLTFLoader+Schema.swift | 7 +- Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 18 +- .../Assets/Shaders/flat3d.glsl | 91 +++++++- .../CorePipelines/PBR3DUniforms.swift | 35 ++- Sources/AdaRender/Materials/PBRMaterial.swift | 88 +++++++- Sources/AdaScene/3D/Model3DPlugin.swift | 12 +- .../AdaScene/3D/ModelAsset3D+Materials.swift | 20 ++ .../GLTFMaterialExtensionTests.swift | 57 +++-- .../Render3DMaterialExtensionTests.swift | 210 +++++++++++++++++- 12 files changed, 638 insertions(+), 57 deletions(-) diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index e89fda071..988181593 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -298,6 +298,7 @@ Each item needs its own short requirements note before implementation. - [x] Stage 2: `KHR_materials_clearcoat` factors (see "Stage 2, second slice"). - [x] Stage 2: parallax occlusion mapping from `PBRMaterial.heightTexture` (world-unit `heightScale`, per-pixel ray march in `flat3d.glsl` that derives the UV shift from screen-space derivatives so no tangents are needed; the height map is read with the base color sampler because Metal has no free sampler slot; depth, shadows and silhouettes are not displaced; no glTF import yet because no displacement extension is ratified). - [x] Stage 2: sheen and anisotropy factors (see "Stage 2, third slice"). +- [x] Stage 2: clearcoat, clearcoat roughness and normal, sheen color and roughness, anisotropy and specular textures (see "Stage 2, fifth slice"). - [ ] Stage 2: `KHR_lights_punctual` import. - [x] Stage 2: unsupported-extension degradation policy and documented supported list (`NativeGLTFLoader.supportedExtensions`, `GLTFImportResult.warnings`). - [ ] Stage 3: morph-target asset, glTF import and GPU evaluation. @@ -850,6 +851,37 @@ used); the motion and shadow passes do not use layers and are not split. The fir a shader. Tests: `shaderFeaturesFollowTheLayersInUse`, `pipelineKeysSplitByFeaturesAndDefaultToThePlainShader`, and the existing clearcoat, sheen and anisotropy GPU tests, which now run through the variants (turning a layer off restores the plain frame). +### Stage 2, fifth slice: layer textures (2026-10-19) + +The clearcoat, sheen, anisotropy and specular extensions now read their textures, which the earlier slices skipped. Eight textures +(`PBRLayerTexture`): clearcoat strength (red), clearcoat roughness (green), clearcoat normal, sheen color (RGB, sRGB), sheen +roughness (alpha), anisotropy direction (red and green) with strength (blue), specular strength (alpha) and specular color +(RGB, sRGB). Each multiplies its factor, as the extension texts require. + +- **Import.** Each reference keeps its texture, UV set (0 or 1, or the `texCoord` of its own `KHR_texture_transform`), transform + and, for the clearcoat normal map, its scale (`GLTFImportResult.LayerTextures`). A bad index, UV set or transform fails with + `invalidMaterial`. Import warnings for skipped layer textures are gone because none are skipped. +- **Binding.** Textures bind at 32 to 39, and only in the shader variant of a material that has them: `PBRMaterial.shaderFeatures` + gained one bit per texture (macros `ADA_CLEARCOAT_TEXTURE` and so on), and a texture selects its bit only while its layer is + on, because it scales a factor that would otherwise be zero. The specular textures belong to no layer. So a material pays for the + textures it has, and the plain shader and the bind groups of other materials are unchanged (`bare` geometry is still 1.77 ms). +- **Sampling.** The main pass has no free sampler slot (Metal allows 16), so every layer texture is read with the base color + texture's sampler: a layer texture whose glTF sampler differs from the base color's (for example another wrap mode) takes the + base color's. UVs come from `layerUV`, which applies the parallax shift of the same UV set and then the texture's transform. The + material uniform carries each texture's UV set and transform (about 330 more bytes per material). +- **Shading.** Clearcoat strength and roughness are scaled by their textures, the clearcoat normal bends the coat on its own + frame (derivative frame; the base normal map is untouched), sheen color and roughness are scaled, the anisotropy texture sets + the direction (rotated by `anisotropyRotation`) and scales the strength, and the specular textures scale the dielectric + reflectance `g_Dielectric`, which replaced the uniform value in every lighting term. + +Limits: the layer textures share the base color sampler; the clearcoat normal map uses the UV-derivative frame (no authored +tangents), so it shares the faceting of that frame on coarse meshes; there is no web build of any layer (variants cannot be compiled +there). Tests: import (`importsEveryLayerTextureWithItsOwnUVSetAndTransform` and the rejection cases), the mapping from an +imported material to `PBRMaterial`, the feature bits and uniform, and one GPU test per texture. They include equivalences (an +anisotropy texture of (1, 0.5, 1) equals the factor alone, and (0.5, 1, 1) equals a quarter turn; a specular alpha of zero equals a +specular factor of zero; a flat clearcoat normal changes nothing) and a transform test that samples either half of a two-tone mask. +Each of the shader reads was checked by removing it and watching its test fail. + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift index 82016608f..8f608f9b5 100644 --- a/Sources/AdaAssets/GLTF/GLTFMaterialData.swift +++ b/Sources/AdaAssets/GLTF/GLTFMaterialData.swift @@ -22,6 +22,57 @@ public extension GLTFImportResult { } } + /// A texture used by a material extension: which texture, the UV set and transform it is read with, and a scale + /// (only the clearcoat normal map has one). + struct TextureReference: Sendable, Equatable { + public let index: Int + public let texCoord: Int + public let transform: TextureTransform + public let scale: Float + + public init(index: Int, texCoord: Int = 0, transform: TextureTransform = .identity, scale: Float = 1) { + self.index = index + self.texCoord = texCoord + self.transform = transform + self.scale = scale + } + } + + /// Textures read by `KHR_materials_clearcoat`, `KHR_materials_sheen`, `KHR_materials_anisotropy` and + /// `KHR_materials_specular`. Channels follow the extension texts: clearcoat in red, clearcoat roughness in green, + /// sheen color in RGB (sRGB), sheen roughness in alpha, anisotropy direction in RG and strength in blue, specular + /// strength in alpha and specular color in RGB (sRGB). + struct LayerTextures: Sendable, Equatable { + public var clearcoat: TextureReference? + public var clearcoatRoughness: TextureReference? + public var clearcoatNormal: TextureReference? + public var sheenColor: TextureReference? + public var sheenRoughness: TextureReference? + public var anisotropy: TextureReference? + public var specular: TextureReference? + public var specularColor: TextureReference? + + public init( + clearcoat: TextureReference? = nil, + clearcoatRoughness: TextureReference? = nil, + clearcoatNormal: TextureReference? = nil, + sheenColor: TextureReference? = nil, + sheenRoughness: TextureReference? = nil, + anisotropy: TextureReference? = nil, + specular: TextureReference? = nil, + specularColor: TextureReference? = nil + ) { + self.clearcoat = clearcoat + self.clearcoatRoughness = clearcoatRoughness + self.clearcoatNormal = clearcoatNormal + self.sheenColor = sheenColor + self.sheenRoughness = sheenRoughness + self.anisotropy = anisotropy + self.specular = specular + self.specularColor = specularColor + } + } + /// glTF metallic/roughness material values; color factors are linear. struct Material: Sendable { public let name: String? @@ -46,18 +97,20 @@ public extension GLTFImportResult { public let textureTransforms: [TextureTransform] /// `KHR_materials_ior`. The glTF default is 1.5, which gives the 4% dielectric reflectance. public let ior: Float - /// `KHR_materials_specular` strength and tint of the dielectric reflection. Specular textures are not read. + /// `KHR_materials_specular` strength and tint of the dielectric reflection. Specular textures are in `layerTextures`. public let specularFactor: Float public let specularColorFactor: Vector3 - /// `KHR_materials_clearcoat` strength and roughness. Clearcoat textures are not read. + /// `KHR_materials_clearcoat` strength and roughness. Clearcoat textures are in `layerTextures`. public let clearcoatFactor: Float public let clearcoatRoughnessFactor: Float - /// `KHR_materials_sheen` color (linear) and roughness. Sheen textures are not read. + /// `KHR_materials_sheen` color (linear) and roughness. Sheen textures are in `layerTextures`. public let sheenColorFactor: Vector3 public let sheenRoughnessFactor: Float - /// `KHR_materials_anisotropy` strength and rotation. The anisotropy texture is not read. + /// `KHR_materials_anisotropy` strength and rotation. The anisotropy texture is in `layerTextures`. public let anisotropyStrength: Float public let anisotropyRotation: Float + /// The textures of the extensions above. + public let layerTextures: LayerTextures public init( name: String?, @@ -86,7 +139,8 @@ public extension GLTFImportResult { sheenColorFactor: Vector3 = .zero, sheenRoughnessFactor: Float = 0, anisotropyStrength: Float = 0, - anisotropyRotation: Float = 0 + anisotropyRotation: Float = 0, + layerTextures: LayerTextures = LayerTextures() ) { self.name = name self.baseColorFactor = baseColorFactor @@ -115,6 +169,7 @@ public extension GLTFImportResult { self.sheenRoughnessFactor = sheenRoughnessFactor self.anisotropyStrength = anisotropyStrength self.anisotropyRotation = anisotropyRotation + self.layerTextures = layerTextures } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift index 5f79c1677..91f1ce37d 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Materials.swift @@ -47,6 +47,31 @@ extension NativeGLTFLoader { return offset.count == 2 && scale.count == 2 && values.allSatisfy { $0.isFinite } } + /// Reads one extension texture reference. Returns `.absent` when there is none and `.invalid` when its index, UV set or + /// transform cannot be used. + private enum LayerReference { + case absent + case invalid + case value(GLTFImportResult.TextureReference) + } + + private func layerReference(_ reference: GLTF.Material.Extensions.TextureReference?, textureCount: Int) -> LayerReference { + guard let reference else { return .absent } + let transformInfo = reference.extensions?.KHR_texture_transform + let texCoord = transformInfo?.texCoord ?? reference.texCoord ?? 0 + let scale: Float = reference.scale ?? 1 + guard reference.index >= 0, reference.index < textureCount, texCoord == 0 || texCoord == 1, scale.isFinite, isValid(transformInfo) else { + return .invalid + } + var transform = GLTFImportResult.TextureTransform.identity + if let transformInfo { + let offset: [Float] = transformInfo.offset ?? [0, 0] + let scaling: [Float] = transformInfo.scale ?? [1, 1] + transform = .init(offset: Vector2(offset[0], offset[1]), rotation: transformInfo.rotation ?? 0, scale: Vector2(scaling[0], scaling[1])) + } + return .value(.init(index: reference.index, texCoord: texCoord, transform: transform, scale: scale)) + } + private func importMaterial(_ material: GLTF.Material, index: Int, textureCount: Int) throws -> GLTFImportResult.Material { let pbr = material.pbrMetallicRoughness let base: [Float] = pbr?.baseColorFactor ?? [1, 1, 1, 1] @@ -92,6 +117,27 @@ extension NativeGLTFLoader { let sheenColor: [Float] = sheen?.sheenColorFactor ?? [0, 0, 0] let sheenRoughness: Float = sheen?.sheenRoughnessFactor ?? 0 let anisotropy = material.extensions?.KHR_materials_anisotropy + let layerInputs: [LayerReference] = [ + layerReference(coat?.clearcoatTexture, textureCount: textureCount), + layerReference(coat?.clearcoatRoughnessTexture, textureCount: textureCount), + layerReference(coat?.clearcoatNormalTexture, textureCount: textureCount), + layerReference(sheen?.sheenColorTexture, textureCount: textureCount), + layerReference(sheen?.sheenRoughnessTexture, textureCount: textureCount), + layerReference(material.extensions?.KHR_materials_anisotropy?.anisotropyTexture, textureCount: textureCount), + layerReference(material.extensions?.KHR_materials_specular?.specularTexture, textureCount: textureCount), + layerReference(material.extensions?.KHR_materials_specular?.specularColorTexture, textureCount: textureCount), + ] + var layerValues: [GLTFImportResult.TextureReference?] = [] + var layerTexturesAreValid = true + for input in layerInputs { + switch input { + case .absent: layerValues.append(nil) + case .invalid: + layerTexturesAreValid = false + layerValues.append(nil) + case let .value(value): layerValues.append(value) + } + } let anisotropyStrength: Float = anisotropy?.anisotropyStrength ?? 0 let anisotropyRotation: Float = anisotropy?.anisotropyRotation ?? 0 let textures: [Int?] = [ @@ -112,7 +158,7 @@ extension NativeGLTFLoader { let anisotropyIsValid = anisotropyStrength.isFinite && anisotropyStrength >= 0 && anisotropyStrength <= 1 && anisotropyRotation.isFinite let specularIsValid = ior.isFinite && ior >= 1 && ior <= 100 && specularFactor.isFinite && specularFactor >= 0 && specularColor.count == 3 && specularColor.allSatisfy { $0.isFinite && $0 >= 0 } - guard factorsAreValid, scalarsAreValid, specularIsValid, coatIsValid, sheenIsValid, anisotropyIsValid, texturesExist, transformsAreValid, + guard factorsAreValid, scalarsAreValid, specularIsValid, coatIsValid, sheenIsValid, anisotropyIsValid, layerTexturesAreValid, texturesExist, transformsAreValid, coordinates.allSatisfy({ $0 == 0 || $0 == 1 }), let alpha = GLTFImportResult.AlphaMode(rawValue: material.alphaMode ?? "OPAQUE") else { throw GLTFError.invalidMaterial(index) } @@ -143,7 +189,17 @@ extension NativeGLTFLoader { sheenColorFactor: Vector3(sheenColor[0], sheenColor[1], sheenColor[2]), sheenRoughnessFactor: sheenRoughness, anisotropyStrength: anisotropyStrength, - anisotropyRotation: anisotropyRotation + anisotropyRotation: anisotropyRotation, + layerTextures: GLTFImportResult.LayerTextures( + clearcoat: layerValues[0], + clearcoatRoughness: layerValues[1], + clearcoatNormal: layerValues[2], + sheenColor: layerValues[3], + sheenRoughness: layerValues[4], + anisotropy: layerValues[5], + specular: layerValues[6], + specularColor: layerValues[7] + ) ) } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift index 3fded40b6..cc3843265 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift @@ -125,7 +125,12 @@ extension NativeGLTFLoader { struct Extensions: Codable { struct EmissiveStrength: Codable { let emissiveStrength: Float } struct IOR: Codable { let ior: Float? } - struct TextureReference: Codable { let index: Int } + struct TextureReference: Codable { + let index: Int + let texCoord: Int? + let scale: Float? + let extensions: TextureExtensions? + } struct Specular: Codable { let specularFactor: Float? let specularColorFactor: [Float]? diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index fde0632ac..43d4f3d2b 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -30,28 +30,12 @@ public struct NativeGLTFLoader: GLTFLoader { "KHR_materials_anisotropy", ] - /// Optional content the importer cannot honor: unimplemented extensions the file lists as used, and the parts - /// of an implemented extension that are skipped (specular textures). + /// Optional content the importer cannot honor: extensions the file lists as used that are not implemented. private func warnings(for gltf: GLTF) -> [String] { var result: [String] = [] for name in (gltf.extensionsUsed ?? []).sorted() where !Self.supportedExtensions.contains(name) { result.append("The extension \(name) is not supported and was ignored.") } - for (index, material) in (gltf.materials ?? []).enumerated() { - if let specular = material.extensions?.KHR_materials_specular, specular.specularTexture != nil || specular.specularColorTexture != nil { - result.append("Material \(index) uses a KHR_materials_specular texture; only its factors were applied.") - } - if let coat = material.extensions?.KHR_materials_clearcoat, - coat.clearcoatTexture != nil || coat.clearcoatRoughnessTexture != nil || coat.clearcoatNormalTexture != nil { - result.append("Material \(index) uses a KHR_materials_clearcoat texture; only its factors were applied.") - } - if let sheen = material.extensions?.KHR_materials_sheen, sheen.sheenColorTexture != nil || sheen.sheenRoughnessTexture != nil { - result.append("Material \(index) uses a KHR_materials_sheen texture; only its factors were applied.") - } - if let anisotropy = material.extensions?.KHR_materials_anisotropy, anisotropy.anisotropyTexture != nil { - result.append("Material \(index) uses a KHR_materials_anisotropy texture; only its factors were applied.") - } - } return result } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index afa56ede1..093ae7275 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -147,8 +147,40 @@ layout (binding = 15) uniform PBR3DUniform { vec4 u_Coat; // x clearcoat strength, y clearcoat roughness (KHR_materials_clearcoat) vec4 u_Sheen; // rgb sheen color, w sheen roughness (KHR_materials_sheen) vec4 u_Anisotropy; // x strength, y rotation in radians (KHR_materials_anisotropy) + // UV set of each layer texture (clearcoat, clearcoat roughness, clearcoat normal, sheen color, sheen roughness, + // anisotropy, specular, specular color), and for each a row-major 2x2 matrix then an offset. + vec4 u_LayerSets[2]; + vec4 u_LayerTransforms[16]; + vec4 u_LayerParameters; // x clearcoat normal scale }; +// Layer textures exist only in the shader variants of materials that have them. They are read with the base color +// sampler, because the main pass has no free sampler slot. +#ifdef ADA_CLEARCOAT_TEXTURE +layout (binding = 32) uniform texture2D u_ClearcoatTexture; +#endif +#ifdef ADA_CLEARCOAT_ROUGHNESS_TEXTURE +layout (binding = 33) uniform texture2D u_ClearcoatRoughnessTexture; +#endif +#ifdef ADA_CLEARCOAT_NORMAL_TEXTURE +layout (binding = 34) uniform texture2D u_ClearcoatNormalTexture; +#endif +#ifdef ADA_SHEEN_COLOR_TEXTURE +layout (binding = 35) uniform texture2D u_SheenColorTexture; +#endif +#ifdef ADA_SHEEN_ROUGHNESS_TEXTURE +layout (binding = 36) uniform texture2D u_SheenRoughnessTexture; +#endif +#ifdef ADA_ANISOTROPY_TEXTURE +layout (binding = 37) uniform texture2D u_AnisotropyTexture; +#endif +#ifdef ADA_SPECULAR_TEXTURE +layout (binding = 38) uniform texture2D u_SpecularTexture; +#endif +#ifdef ADA_SPECULAR_COLOR_TEXTURE +layout (binding = 39) uniform texture2D u_SpecularColorTexture; +#endif + vec2 applyTextureTransform(vec2 uv, int slot) { vec4 matrix = u_TextureTransforms[slot * 2]; return vec2(dot(matrix.xy, uv), dot(matrix.zw, uv)) + u_TextureTransforms[slot * 2 + 1].xy; @@ -272,6 +304,9 @@ float coatTransmission(float normalView) { return 1.0 - g_Clearcoat.x * fresnelSchlick(normalView, vec3(0.04)).x; } +// Reflectance of non-metals at normal incidence for this fragment: the material value times its specular textures. +vec3 g_Dielectric = vec3(0.04); + // Sheen layer (cloth): rgb color, w roughness. A black color turns it off. vec4 g_Sheen = vec4(0.0); // Average share of light the sheen lobe takes from the base layer. The glTF sheen albedo lookup is replaced by this constant. @@ -310,9 +345,19 @@ vec2 materialUV(float setIndex, int slot) { vec2 uv = setIndex > 0.5 ? Input.TextureCoordinate1 : Input.TextureCoordinate; // The parallax shift lives in mesh UV space, so it applies before the texture's own transform. if (abs(setIndex - u_Parallax.y) < 0.5) { uv += g_ParallaxDelta; } + return applyTextureTransform(uv, slot); } +// UV of a layer texture (slot 0 to 7 in PBRLayerTexture order): its UV set, with the parallax shift, then its own transform. +vec2 layerUV(int slot) { + float setIndex = u_LayerSets[slot >> 2][slot & 3]; + vec2 uv = setIndex > 0.5 ? Input.TextureCoordinate1 : Input.TextureCoordinate; + if (abs(setIndex - u_Parallax.y) < 0.5) { uv += g_ParallaxDelta; } + vec4 matrix = u_LayerTransforms[slot * 2]; + return vec2(dot(matrix.xy, uv), dot(matrix.zw, uv)) + u_LayerTransforms[slot * 2 + 1].xy; +} + // Ray-marches the height map along the view ray. Returns the UV shift that makes the surface appear to have depth. // Depth is measured in view units: a surface point `d * scale` below the top shifts by `d * scale` along the view // ray's projection onto the surface, converted to UV through the screen-space position/UV derivatives so no @@ -380,7 +425,7 @@ vec3 environmentLighting(vec3 normal, vec3 viewDirection, vec3 baseColor, float vec3 worldNormal = normalize(mat3(u_EnvironmentInverseView) * normal); vec3 worldView = normalize(mat3(u_EnvironmentInverseView) * viewDirection); float ndotv = max(dot(normal, viewDirection), 0.0); - vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); + vec3 f0 = mix(g_Dielectric, baseColor, metallic); vec3 fresnel = f0 + (max(vec3(1.0 - roughness), f0) - f0) * pow(1.0 - ndotv, 5.0); vec3 diffuseWeight = (vec3(1.0) - fresnel) * (1.0 - metallic); vec3 irradiance = texture(sampler2D(u_Irradiance, u_IBLSampler), environmentUV(worldNormal)).rgb; @@ -522,7 +567,7 @@ vec3 evaluateDirect(vec3 normal, vec3 viewDirection, vec3 lightDirection, vec3 b vec3 base = vec3(0.0); if (nl > 0.0 && nv > 0.0) { vec3 halfway = normalize(viewDirection + lightDirection); - vec3 f0 = mix(u_Dielectric.rgb, baseColor, metallic); + vec3 f0 = mix(g_Dielectric, baseColor, metallic); vec3 fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); vec3 specular; if (hasAnisotropy()) { @@ -685,19 +730,59 @@ void flat3d_fragment() vec3 normal = materialNormal(); if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { normal = -normal; } + // Shading layers: each factor is multiplied by its texture when the material has one. + g_Dielectric = u_Dielectric.rgb; +#ifdef ADA_SPECULAR_TEXTURE + g_Dielectric *= texture(sampler2D(u_SpecularTexture, u_BaseColorSampler), layerUV(6)).a; +#endif +#ifdef ADA_SPECULAR_COLOR_TEXTURE + g_Dielectric *= srgbToLinear(texture(sampler2D(u_SpecularColorTexture, u_BaseColorSampler), layerUV(7)).rgb); +#endif + g_Sheen = vec4(max(u_Sheen.rgb, vec3(0.0)), clamp(u_Sheen.w, 0.07, 1.0)); +#ifdef ADA_SHEEN_COLOR_TEXTURE + g_Sheen.rgb *= srgbToLinear(texture(sampler2D(u_SheenColorTexture, u_BaseColorSampler), layerUV(3)).rgb); +#endif +#ifdef ADA_SHEEN_ROUGHNESS_TEXTURE + g_Sheen.w = clamp(u_Sheen.w * texture(sampler2D(u_SheenRoughnessTexture, u_BaseColorSampler), layerUV(4)).a, 0.07, 1.0); +#endif + g_Anisotropy = clamp(u_Anisotropy.x, 0.0, 1.0); + vec2 anisotropyDirection = vec2(1.0, 0.0); +#ifdef ADA_ANISOTROPY_TEXTURE + // Red and green hold the direction in tangent space, blue scales the strength. + vec3 anisotropySample = texture(sampler2D(u_AnisotropyTexture, u_BaseColorSampler), layerUV(5)).rgb; + g_Anisotropy = clamp(g_Anisotropy * anisotropySample.b, 0.0, 1.0); + vec2 sampledDirection = anisotropySample.rg * 2.0 - 1.0; + if (dot(sampledDirection, sampledDirection) > 0.0001) { anisotropyDirection = normalize(sampledDirection); } +#endif if (hasAnisotropy()) { bool authored = Input.TextureFlags.w > 0.5; vec3 tangent = authored ? Input.ViewTangent.xyz : cotangentFrame(normalize(Input.ViewNormal), Input.ViewPosition, materialUV(u_UVSets.x, 0))[0]; tangent = normalize(tangent - normal * dot(normal, tangent)); vec3 bitangent = normalize(cross(normal, tangent)) * (authored ? Input.ViewTangent.w : 1.0); - g_AnisotropyT = normalize(cos(u_Anisotropy.y) * tangent + sin(u_Anisotropy.y) * bitangent); + // The rotation turns the direction, whether it comes from the texture or is the tangent itself. + float rotationCosine = cos(u_Anisotropy.y), rotationSine = sin(u_Anisotropy.y); + vec2 turned = vec2(rotationCosine * anisotropyDirection.x - rotationSine * anisotropyDirection.y, + rotationSine * anisotropyDirection.x + rotationCosine * anisotropyDirection.y); + g_AnisotropyT = normalize(turned.x * tangent + turned.y * bitangent); g_AnisotropyB = normalize(cross(normal, g_AnisotropyT)); } + g_Clearcoat = vec2(u_Coat.x, clamp(u_Coat.y, 0.04, 1.0)); g_CoatNormal = normalize(Input.ViewNormal); if (!gl_FrontFacing && u_SurfaceFlags.x > 0.5) { g_CoatNormal = -g_CoatNormal; } +#ifdef ADA_CLEARCOAT_TEXTURE + g_Clearcoat.x *= texture(sampler2D(u_ClearcoatTexture, u_BaseColorSampler), layerUV(0)).r; +#endif +#ifdef ADA_CLEARCOAT_ROUGHNESS_TEXTURE + g_Clearcoat.y = clamp(u_Coat.y * texture(sampler2D(u_ClearcoatRoughnessTexture, u_BaseColorSampler), layerUV(1)).g, 0.04, 1.0); +#endif +#ifdef ADA_CLEARCOAT_NORMAL_TEXTURE + vec3 coatTangentNormal = texture(sampler2D(u_ClearcoatNormalTexture, u_BaseColorSampler), layerUV(2)).xyz * 2.0 - 1.0; + coatTangentNormal.xy *= u_LayerParameters.x; + g_CoatNormal = normalize(cotangentFrame(g_CoatNormal, Input.ViewPosition, layerUV(2)) * coatTangentNormal); +#endif vec3 viewDirection = normalize(-Input.ViewPosition); vec3 lightDirection = normalize(u_LightDirectionIntensity.xyz); gl_FragDepth = gl_FragCoord.z; diff --git a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift index 93aadea24..927ce5005 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift @@ -43,6 +43,20 @@ public struct PBR3DUniform: Sendable, Equatable { public let sheen: Vector4 /// X is the anisotropy strength and y its rotation in radians. public let anisotropy: Vector4 + /// UV sets (0 or 1) of the layer textures in `PBRLayerTexture` order: clearcoat to sheen color, then sheen roughness to specular color. + public let layerSets0: Vector4 + public let layerSets1: Vector4 + /// UV transforms of the layer textures in `PBRLayerTexture` order. + public let layerTransform0: PBR3DTextureTransform + public let layerTransform1: PBR3DTextureTransform + public let layerTransform2: PBR3DTextureTransform + public let layerTransform3: PBR3DTextureTransform + public let layerTransform4: PBR3DTextureTransform + public let layerTransform5: PBR3DTextureTransform + public let layerTransform6: PBR3DTextureTransform + public let layerTransform7: PBR3DTextureTransform + /// X is the clearcoat normal scale. + public let layerParameters: Vector4 public init(material: PBRMaterial?, descriptor _: VertexDescriptor) { func coordinate(_ slot: Int) -> Int { @@ -91,6 +105,20 @@ public struct PBR3DUniform: Sendable, Equatable { 0, 0 ) + func layer(_ texture: PBRLayerTexture) -> PBRTextureBinding? { material?.layerTextures[texture] } + func layerSet(_ texture: PBRLayerTexture) -> Float { Float(min(max(layer(texture)?.texCoord ?? 0, 0), 1)) } + func layerTransform(_ texture: PBRLayerTexture) -> PBR3DTextureTransform { PBR3DTextureTransform(layer(texture)?.transform) } + layerSets0 = Vector4(layerSet(.clearcoat), layerSet(.clearcoatRoughness), layerSet(.clearcoatNormal), layerSet(.sheenColor)) + layerSets1 = Vector4(layerSet(.sheenRoughness), layerSet(.anisotropy), layerSet(.specular), layerSet(.specularColor)) + layerTransform0 = layerTransform(.clearcoat) + layerTransform1 = layerTransform(.clearcoatRoughness) + layerTransform2 = layerTransform(.clearcoatNormal) + layerTransform3 = layerTransform(.sheenColor) + layerTransform4 = layerTransform(.sheenRoughness) + layerTransform5 = layerTransform(.anisotropy) + layerTransform6 = layerTransform(.specular) + layerTransform7 = layerTransform(.specularColor) + layerParameters = Vector4(finiteRenderValue(layer(.clearcoatNormal)?.scale ?? 1, fallback: 1, range: 0...100), 0, 0, 0) } /// Reflectance at normal incidence of the dielectric layer: ((ior - 1) / (ior + 1))^2 tinted and scaled by the @@ -115,7 +143,12 @@ public struct PBR3DUniform: Sendable, Equatable { bits(emissiveFactor), bits(properties), bits(flags), bits(coordinates), bits(emission), bits(parallax), bits(transform0.matrix), bits(transform0.offset), bits(transform1.matrix), bits(transform1.offset), bits(transform2.matrix), bits(transform2.offset), bits(transform3.matrix), bits(transform3.offset), - bits(transform4.matrix), bits(transform4.offset), bits(dielectric), bits(coat), bits(sheen), bits(anisotropy), + bits(transform4.matrix), bits(transform4.offset), bits(dielectric), bits(coat), bits(sheen), bits(anisotropy), bits(layerSets0), bits(layerSets1), + bits(layerTransform0.matrix), bits(layerTransform0.offset), bits(layerTransform1.matrix), bits(layerTransform1.offset), + bits(layerTransform2.matrix), bits(layerTransform2.offset), bits(layerTransform3.matrix), bits(layerTransform3.offset), + bits(layerTransform4.matrix), bits(layerTransform4.offset), bits(layerTransform5.matrix), bits(layerTransform5.offset), + bits(layerTransform6.matrix), bits(layerTransform6.offset), bits(layerTransform7.matrix), bits(layerTransform7.offset), + bits(layerParameters), ] } } diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index 09a43b094..41c619cf0 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -25,6 +25,74 @@ public struct PBRTextureTransform: Sendable, Equatable { } } +/// A texture read by one of the material's shading layers, with the UV set and transform it is read with. +public struct PBRTextureBinding: Sendable { + public var texture: Texture2D + /// UV set (0 or 1). + public var texCoord: Int + public var transform: PBRTextureTransform + /// Scale of the sampled value; only the clearcoat normal map uses it. + public var scale: Float + + public init(texture: Texture2D, texCoord: Int = 0, transform: PBRTextureTransform = .identity, scale: Float = 1) { + self.texture = texture + self.texCoord = texCoord + self.transform = transform + self.scale = scale + } +} + +/// The textures of the shading layers and of the specular extension, and where they bind in the main shader. +/// +/// They are read with the base color texture's sampler, because the main pass has no free sampler slots. +public enum PBRLayerTexture: Int, CaseIterable, Hashable, Sendable { + /// Clearcoat strength (red). + case clearcoat + /// Clearcoat roughness (green). + case clearcoatRoughness + /// Tangent-space normal of the clearcoat layer. + case clearcoatNormal + /// Sheen color (RGB, sRGB). + case sheenColor + /// Sheen roughness (alpha). + case sheenRoughness + /// Anisotropy direction (red, green) and strength (blue). + case anisotropy + /// Specular strength (alpha). + case specular + /// Specular color (RGB, sRGB). + case specularColor + + /// The texture binding in the main shader. + public var binding: Int { 32 + rawValue } + + /// The feature bit that selects the shader variant declaring this texture. + public var feature: PBRMaterial.ShaderFeatures { PBRMaterial.ShaderFeatures(rawValue: 1 << UInt32(3 + rawValue)) } + + var macro: String { + switch self { + case .clearcoat: "ADA_CLEARCOAT_TEXTURE" + case .clearcoatRoughness: "ADA_CLEARCOAT_ROUGHNESS_TEXTURE" + case .clearcoatNormal: "ADA_CLEARCOAT_NORMAL_TEXTURE" + case .sheenColor: "ADA_SHEEN_COLOR_TEXTURE" + case .sheenRoughness: "ADA_SHEEN_ROUGHNESS_TEXTURE" + case .anisotropy: "ADA_ANISOTROPY_TEXTURE" + case .specular: "ADA_SPECULAR_TEXTURE" + case .specularColor: "ADA_SPECULAR_COLOR_TEXTURE" + } + } + + /// The layer a texture belongs to, if it only matters while that layer is on. + fileprivate var layer: PBRMaterial.ShaderFeatures? { + switch self { + case .clearcoat, .clearcoatRoughness, .clearcoatNormal: .clearcoat + case .sheenColor, .sheenRoughness: .sheen + case .anisotropy: .anisotropy + case .specular, .specularColor: nil + } + } +} + /// A material that uses Physically Based Rendering (PBR) to define its appearance. public class PBRMaterial: Material, @unchecked Sendable { public var baseColorFactor: Vector4 = .one { didSet { uniformRevision &+= 1 } } @@ -82,9 +150,11 @@ public class PBRMaterial: Material, @unchecked Sendable { public var anisotropyStrength: Float = 0 { didSet { uniformRevision &+= 1 } } /// Rotation of the stretch direction from the surface tangent, in radians. public var anisotropyRotation: Float = 0 { didSet { uniformRevision &+= 1 } } + /// Textures of the clearcoat, sheen, anisotropy and specular layers. Each multiplies its layer's factor. + public var layerTextures: [PBRLayerTexture: PBRTextureBinding] = [:] { didSet { uniformRevision &+= 1 } } - /// The optional shading layers a material needs. The main shader is compiled with only the layers a material uses; - /// the empty set is the plain shader, which is also the only one that has a shader cache and a web build. + /// The optional shading layers and layer textures a material needs. The main shader is compiled with only what a + /// material uses; the empty set is the plain shader, which is also the only one that has a shader cache and a web build. public struct ShaderFeatures: OptionSet, Hashable, Sendable { public let rawValue: UInt32 public init(rawValue: UInt32) { self.rawValue = rawValue } @@ -93,25 +163,33 @@ public class PBRMaterial: Material, @unchecked Sendable { public static let sheen = ShaderFeatures(rawValue: 1 << 1) public static let anisotropy = ShaderFeatures(rawValue: 1 << 2) - /// Every layer. + /// Every layer, without textures. public static let all: ShaderFeatures = [.clearcoat, .sheen, .anisotropy] - /// Preprocessor names that switch each layer in this set on. Empty for the plain shader. + /// Preprocessor names that switch each layer and texture in this set on. Empty for the plain shader. public var enablingMacros: [String] { var macros: [String] = [] if contains(.clearcoat) { macros.append("ADA_CLEARCOAT") } if contains(.sheen) { macros.append("ADA_SHEEN") } if contains(.anisotropy) { macros.append("ADA_ANISOTROPY") } + for texture in PBRLayerTexture.allCases where contains(texture.feature) { + macros.append(texture.macro) + } return macros } } - /// The layers this material currently uses, from its factor values. + /// The layers and textures this material currently uses, from its factor values and textures. public var shaderFeatures: ShaderFeatures { var features: ShaderFeatures = [] if clearcoatFactor > 0 { features.insert(.clearcoat) } if max(sheenColorFactor.x, max(sheenColorFactor.y, sheenColorFactor.z)) > 0 { features.insert(.sheen) } if anisotropyStrength > 0 { features.insert(.anisotropy) } + for texture in layerTextures.keys { + // A layer texture scales its layer's factor, so with the layer off it changes nothing. + if let layer = texture.layer, !features.contains(layer) { continue } + features.insert(texture.feature) + } return features } diff --git a/Sources/AdaScene/3D/Model3DPlugin.swift b/Sources/AdaScene/3D/Model3DPlugin.swift index 6de7c4753..d1ce5f668 100644 --- a/Sources/AdaScene/3D/Model3DPlugin.swift +++ b/Sources/AdaScene/3D/Model3DPlugin.swift @@ -217,6 +217,16 @@ public final class Model3DDrawPass: DrawPass, @unchecked Sendable { let normalTexture = pbrMaterial?.normalTexture ?? Self.flatNormalTexture let emissiveTexture = pbrMaterial?.emissiveTexture ?? Texture2D.whiteTexture let heightTexture = pbrMaterial?.heightTexture ?? Texture2D.whiteTexture + // The main shader variant declares exactly the layer textures the material has (see PBRMaterial.shaderFeatures). + var layerBindings: [RenderResourceSet.Binding] = [] + if !isMotion, let pbrMaterial { + let features = pbrMaterial.shaderFeatures + for kind in PBRLayerTexture.allCases where features.contains(kind.feature) { + if let layer = pbrMaterial.layerTextures[kind] { + layerBindings.append(.init(binding: kind.binding, shaderStages: .fragment, resource: .texture(layer.texture))) + } + } + } if isMotion { let previous = world.getResource(Skinning3DUniforms.self)?.previousBuffer(for: item.entity) ?? palette @@ -241,7 +251,7 @@ public final class Model3DDrawPass: DrawPass, @unchecked Sendable { .init(binding: 12, shaderStages: .fragment, resource: .texture(emissiveTexture)), .init(binding: 13, shaderStages: .fragment, resource: .sampler(emissiveTexture.sampler)), .init(binding: 17, shaderStages: .fragment, resource: .texture(heightTexture)), - ] + ] + layerBindings ), index: 0 ) diff --git a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift index 250454c4d..69fe97327 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift @@ -81,6 +81,26 @@ extension ModelAsset3D { material.normalTexture = try texture(source.normalTextureIndex, sRGB: false) material.occlusionTexture = try texture(source.occlusionTextureIndex, sRGB: false) material.emissiveTexture = try texture(source.emissiveTextureIndex, sRGB: true) + let layers = source.layerTextures + let layerSources: [(PBRLayerTexture, GLTFImportResult.TextureReference?, Bool)] = [ + (.clearcoat, layers.clearcoat, false), + (.clearcoatRoughness, layers.clearcoatRoughness, false), + (.clearcoatNormal, layers.clearcoatNormal, false), + (.sheenColor, layers.sheenColor, true), + (.sheenRoughness, layers.sheenRoughness, false), + (.anisotropy, layers.anisotropy, false), + (.specular, layers.specular, false), + (.specularColor, layers.specularColor, true), + ] + for (kind, reference, isSRGB) in layerSources { + guard let reference, let value = try texture(reference.index, sRGB: isSRGB) else { continue } + material.layerTextures[kind] = PBRTextureBinding( + texture: value, + texCoord: reference.texCoord, + transform: PBRTextureTransform(offset: reference.transform.offset, rotation: reference.transform.rotation, scale: reference.transform.scale), + scale: reference.scale + ) + } return material } return materials.isEmpty ? [PBRMaterial()] : materials diff --git a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift index 274eb40cf..6499c3b2c 100644 --- a/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift +++ b/Tests/AdaAssetsTests/GLTFMaterialExtensionTests.swift @@ -102,15 +102,6 @@ struct GLTFMaterialExtensionTests { #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { try load(material: ["extensions": extensions]) } } - @Test - func clearcoatTexturesAreReportedAsSkipped() throws { - let result = try load( - material: ["extensions": ["KHR_materials_clearcoat": ["clearcoatFactor": 1, "clearcoatNormalTexture": ["index": 0]]]], - used: ["KHR_materials_clearcoat"] - ) - #expect(result.warnings.count == 1 && result.warnings[0].contains("KHR_materials_clearcoat") && result.warnings[0].contains("Material 0")) - } - @Test func importsSheenAndAnisotropy() throws { let material = try #require(load(material: ["extensions": [ @@ -137,13 +128,43 @@ struct GLTFMaterialExtensionTests { } @Test - func sheenAndAnisotropyTexturesAreReportedAsSkipped() throws { + func importsEveryLayerTextureWithItsOwnUVSetAndTransform() throws { + func reference(_ values: [String: Any] = [:]) -> [String: Any] { ["index": 0].merging(values) { $1 } } let result = try load(material: ["extensions": [ - "KHR_materials_sheen": ["sheenColorTexture": ["index": 0]], - "KHR_materials_anisotropy": ["anisotropyTexture": ["index": 0]], - ]], used: ["KHR_materials_sheen", "KHR_materials_anisotropy"]) - #expect(result.warnings.count == 2) - #expect(result.warnings.contains { $0.contains("KHR_materials_sheen") } && result.warnings.contains { $0.contains("KHR_materials_anisotropy") }) + "KHR_materials_clearcoat": [ + "clearcoatFactor": 1, + "clearcoatTexture": reference(), + "clearcoatRoughnessTexture": reference(["texCoord": 1]), + "clearcoatNormalTexture": reference(["scale": 0.4]), + ], + "KHR_materials_sheen": ["sheenColorTexture": reference(), "sheenRoughnessTexture": reference()], + "KHR_materials_anisotropy": ["anisotropyTexture": reference(transform(["rotation": 0.5]))], + "KHR_materials_specular": [ + "specularTexture": reference(transform(["scale": [2, 3], "texCoord": 1])), + "specularColorTexture": reference(), + ], + ]], used: ["KHR_materials_clearcoat", "KHR_materials_sheen", "KHR_materials_anisotropy", "KHR_materials_specular"]) + let layers = try #require(result.materials.first).layerTextures + #expect(layers.clearcoat == .init(index: 0) && layers.sheenColor == .init(index: 0) && layers.specularColor == .init(index: 0)) + #expect(layers.clearcoatRoughness?.texCoord == 1 && layers.clearcoatNormal?.scale == 0.4) + #expect(layers.anisotropy?.transform.rotation == 0.5) + #expect(layers.specular?.transform.scale == Vector2(2, 3), "A texture transform applies to a layer texture.") + #expect(layers.specular?.texCoord == 1, "A texture transform can pick the UV set for a layer texture.") + #expect(layers.sheenRoughness != nil) + #expect(result.warnings.isEmpty, "Supported layer textures must not be reported as skipped.") + let plain = try #require(load(material: [:]).materials.first) + #expect(plain.layerTextures == .init()) + } + + @Test(arguments: [ + #"{"KHR_materials_clearcoat":{"clearcoatTexture":{"index":9}}}"#, + #"{"KHR_materials_sheen":{"sheenColorTexture":{"index":0,"texCoord":2}}}"#, + #"{"KHR_materials_specular":{"specularTexture":{"index":0,"extensions":{"KHR_texture_transform":{"offset":[1]}}}}}"#, + #"{"KHR_materials_anisotropy":{"anisotropyTexture":{"index":-1}}}"#, + ]) + func rejectsLayerTexturesThatCannotBeRead(json: String) throws { + let extensions = try object(json) + #expect(throws: NativeGLTFLoader.GLTFError.invalidMaterial(0)) { try load(material: ["extensions": extensions]) } } @Test @@ -160,12 +181,6 @@ struct GLTFMaterialExtensionTests { } } - @Test - func specularTexturesAreReportedAsSkipped() throws { - let result = try load(material: ["extensions": ["KHR_materials_specular": ["specularTexture": ["index": 0]]]]) - #expect(result.warnings.contains { $0.contains("Material 0") && $0.contains("texture") }) - } - @Test func theSupportedListNamesEveryImplementedExtension() { let supported = NativeGLTFLoader.supportedExtensions diff --git a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift index 94a88cd87..25ed81625 100644 --- a/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift +++ b/Tests/AdaEngineTests/Render3DMaterialExtensionTests.swift @@ -1,7 +1,8 @@ #if os(macOS) +import AdaAssets import AdaECS @_spi(Internal) import AdaRender -import AdaScene +@testable import AdaScene import AdaTransform import AdaUtils import Foundation @@ -189,6 +190,18 @@ extension Render3DGPUTests { return gain } + /// The largest shift toward red (red minus blue) a frame gained over `baseline` in the middle of the frame. + private static func largestRedShift(_ image: Image, over baseline: Image) -> Float { + var shift: Float = 0 + for y in stride(from: 130, to: 350, by: 2) { + for x in stride(from: 170, to: 470, by: 2) { + let now = image.getPixel(x: x, y: y), before = baseline.getPixel(x: x, y: y) + shift = max(shift, (now.red - now.blue) - (before.red - before.blue)) + } + } + return shift + } + private static func matches(_ left: Image, _ right: Image) -> Bool { GoldenFrame.compare(left, right, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance) } @@ -312,6 +325,201 @@ extension Render3DGPUTests { #expect(Self.matches(round, try await scene.render(.none)), "Zero strength must restore the round highlight.") } + // MARK: Layer textures + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func layerTexturesSelectShaderFeaturesAndReachTheUniform() async throws { + _ = try await Render3DDebugViewGPUTests.makeScene() + let material = PBRMaterial() + // A layer texture scales its layer's factor, so without the layer it selects nothing. + material.layerTextures[.clearcoat] = PBRTextureBinding(texture: Self.solid(1)) + material.layerTextures[.sheenColor] = PBRTextureBinding(texture: Self.solid(1)) + #expect(material.shaderFeatures.isEmpty) + // The specular textures belong to no layer. + material.layerTextures[.specular] = PBRTextureBinding(texture: Self.solid(1)) + #expect(material.shaderFeatures == PBRLayerTexture.specular.feature) + material.clearcoatFactor = 1 + #expect(material.shaderFeatures == [.clearcoat, PBRLayerTexture.clearcoat.feature, PBRLayerTexture.specular.feature]) + #expect(Set(material.shaderFeatures.enablingMacros) == ["ADA_CLEARCOAT", "ADA_CLEARCOAT_TEXTURE", "ADA_SPECULAR_TEXTURE"]) + #expect(Set(PBRLayerTexture.allCases.map(\.feature)).count == 8 && PBRLayerTexture.allCases.map(\.binding) == Array(32...39)) + #expect(Set(PBRLayerTexture.allCases.map(\.feature)).isDisjoint(with: [.clearcoat, .sheen, .anisotropy])) + + material.layerTextures[.clearcoatNormal] = PBRTextureBinding( + texture: Self.solid(1), texCoord: 1, transform: PBRTextureTransform(offset: Vector2(0.5, 0), scale: Vector2(2, 2)), scale: 0.3 + ) + let value = PBR3DUniform(material: material, descriptor: VertexDescriptor()) + #expect(value.layerSets0 == Vector4(0, 0, 1, 0) && value.layerSets1 == .zero) + #expect(value.layerTransform2.matrix == Vector4(2, 0, 0, 2) && value.layerTransform2.offset == Vector4(0.5, 0, 0, 0)) + #expect(value.layerTransform0.matrix == Vector4(1, 0, 0, 1)) + #expect(value.layerParameters.x == 0.3) + let plain = PBR3DUniform(material: PBRMaterial(), descriptor: VertexDescriptor()) + #expect(plain.layerSets0 == .zero && plain.layerParameters.x == 1) + #expect(plain.bitPatterns != value.bitPatterns) + let revision = material.uniformRevision + material.layerTextures[.clearcoat] = nil + #expect(material.uniformRevision > revision, "Adding or removing a layer texture must refresh the shared uniform.") + } + + private static func solid(_ red: Float, _ green: Float = 0, _ blue: Float = 0, _ alpha: Float = 1) -> Texture2D { + Texture2D(image: Image(width: 1, height: 1, color: Color(red: red, green: green, blue: blue, alpha: alpha))) + } + + /// Eight texels that are black on the left half and white on the right half. + private static func halves() -> Texture2D { + var image = Image(width: 8, height: 1, color: .black) + for x in 4..<8 { image.setPixel(in: Point(Float(x), 0), color: .white) } + return Texture2D(image: image) + } + + private static let dark: (PBRMaterial) -> Void = { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.9 } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aClearcoatTextureMasksTheCoat() async throws { + let (scene, material) = try await Self.makeScene(Self.dark) + let baseline = try await scene.render(.none) + material.clearcoatFactor = 1 + material.clearcoatRoughnessFactor = 0.05 + let full = Self.largestGain(try await scene.render(.none), over: baseline) + var gains: [Float] = [] + for mask: Float in [0, 0.4, 0.8] { + material.layerTextures[.clearcoat] = PBRTextureBinding(texture: Self.solid(mask)) + let image = try await scene.render(.none) + gains.append(Self.largestGain(image, over: baseline)) + if mask == 0 { #expect(Self.matches(baseline, image), "A black coat mask must leave the paint uncoated.") } + } + #expect(zip(gains, gains.dropFirst()).allSatisfy { $0 < $1 } && gains[2] < full, "The coat must follow its mask: \(gains) against \(full) unmasked.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aClearcoatRoughnessTextureSetsTheCoatRoughness() async throws { + let (scene, material) = try await Self.makeScene(Self.dark) + let baseline = try await scene.render(.none) + material.clearcoatFactor = 1 + material.clearcoatRoughnessFactor = 0.05 + let glossy = Self.largestGain(try await scene.render(.none), over: baseline) + material.clearcoatRoughnessFactor = 1 + let blurred = Self.largestGain(try await scene.render(.none), over: baseline) + material.layerTextures[.clearcoatRoughness] = PBRTextureBinding(texture: Self.solid(0, 0.05)) + let textured = Self.largestGain(try await scene.render(.none), over: baseline) + #expect(textured > blurred * 1.5 && abs(textured - glossy) < glossy * 0.3, "A green texel of 0.05 must act like a roughness of 0.05 (\(textured) vs \(glossy), rough \(blurred)).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aClearcoatNormalTextureBendsTheCoatOnly() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.2, 0.2, 0.2, 1]; $0.roughnessFactor = 0.6; $0.clearcoatFactor = 1; $0.clearcoatRoughnessFactor = 0.08 } + let smooth = try await scene.render(.none) + material.layerTextures[.clearcoatNormal] = PBRTextureBinding(texture: Self.solid(0.5, 0.5, 1)) + #expect(Self.matches(smooth, try await scene.render(.none)), "A flat normal map must not change the coat.") + material.layerTextures[.clearcoatNormal] = PBRTextureBinding(texture: Self.solid(0.95, 0.5, 0.7)) + #expect(!Self.matches(smooth, try await scene.render(.none)), "A tilted normal map must move the coat's highlight.") + material.layerTextures[.clearcoatNormal] = PBRTextureBinding(texture: Self.solid(0.95, 0.5, 0.7), scale: 0) + #expect(Self.matches(smooth, try await scene.render(.none)), "A normal scale of zero must flatten the map.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func sheenTexturesScaleTheirFactors() async throws { + let (scene, material) = try await Self.makeScene(Self.dark) + let baseline = try await scene.render(.none) + material.sheenColorFactor = Vector3(1, 1, 1) + material.sheenRoughnessFactor = 0.4 + let plain = Self.largestGain(try await scene.render(.none), over: baseline) + material.layerTextures[.sheenColor] = PBRTextureBinding(texture: Self.solid(0, 0, 0)) + #expect(Self.matches(baseline, try await scene.render(.none)), "A black sheen color texture must remove the sheen.") + material.layerTextures[.sheenColor] = PBRTextureBinding(texture: Self.solid(1, 0, 0)) + let red = try await scene.render(.none) + #expect(Self.largestGain(red, over: baseline) > 0.05 && Self.largestGain(red, over: baseline) < plain, "A red sheen color adds less light than white (\(Self.largestGain(red, over: baseline)) vs \(plain)).") + material.layerTextures[.sheenColor] = nil + material.layerTextures[.sheenRoughness] = PBRTextureBinding(texture: Self.solid(0, 0, 0, 0.1)) + let tightened = try await scene.render(.none) + material.layerTextures[.sheenRoughness] = nil + #expect(!Self.matches(tightened, try await scene.render(.none)), "A sheen roughness texture must change the lobe.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func anAnisotropyTextureSuppliesStrengthAndDirection() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.35 } + let round = try await scene.render(.none) + material.anisotropyStrength = 1 + let alongTangent = try await scene.render(.none) + material.anisotropyRotation = .pi / 2 + let alongBitangent = try await scene.render(.none) + material.anisotropyRotation = 0 + // Direction (1, 0) is the tangent itself and (0, 1) the bitangent; blue is the strength. + material.layerTextures[.anisotropy] = PBRTextureBinding(texture: Self.solid(1, 0.5, 1)) + #expect(Self.matches(alongTangent, try await scene.render(.none)), "A texture direction of (1, 0) must equal the tangent direction.") + material.layerTextures[.anisotropy] = PBRTextureBinding(texture: Self.solid(0.5, 1, 1)) + #expect(Self.matches(alongBitangent, try await scene.render(.none)), "A texture direction of (0, 1) must equal a quarter turn.") + material.layerTextures[.anisotropy] = PBRTextureBinding(texture: Self.solid(1, 0.5, 0)) + #expect(Self.matches(round, try await scene.render(.none)), "A blue strength of zero must remove the anisotropy.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func specularTexturesScaleTheDielectricReflection() async throws { + let (scene, material) = try await Self.makeScene { $0.baseColorFactor = [0.03, 0.03, 0.03, 1]; $0.roughnessFactor = 0.2 } + let standard = try await scene.render(.none) + material.specularFactor = 0 + let none = try await scene.render(.none) + material.specularFactor = 1 + material.layerTextures[.specular] = PBRTextureBinding(texture: Self.solid(1, 1, 1, 0)) + #expect(Self.matches(none, try await scene.render(.none)), "A specular texture alpha of zero must equal a specular factor of zero.") + material.layerTextures[.specular] = PBRTextureBinding(texture: Self.solid(1, 1, 1, 1)) + #expect(Self.matches(standard, try await scene.render(.none)), "A specular texture alpha of one must change nothing.") + material.layerTextures[.specular] = nil + material.ior = 2.5 + let neutral = try await scene.render(.none) + material.layerTextures[.specularColor] = PBRTextureBinding(texture: Self.solid(1, 0, 0)) + #expect(Self.largestRedShift(try await scene.render(.none), over: neutral) > 0.1, "A red specular color texture must shift the highlight toward red.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func aLayerTextureReadsThroughItsOwnTransform() async throws { + let (scene, material) = try await Self.makeScene(Self.dark) + let baseline = try await scene.render(.none) + material.clearcoatFactor = 1 + material.clearcoatRoughnessFactor = 0.05 + // Stretching the sampled range to a sliver sends every pixel to one half of the mask. + let squeeze = Vector2(0.0001, 1) + material.layerTextures[.clearcoat] = PBRTextureBinding(texture: Self.halves(), transform: PBRTextureTransform(offset: Vector2(0.9, 0), scale: squeeze)) + let white = Self.largestGain(try await scene.render(.none), over: baseline) + material.layerTextures[.clearcoat] = PBRTextureBinding(texture: Self.halves(), transform: PBRTextureTransform(offset: Vector2(0.1, 0), scale: squeeze)) + let black = try await scene.render(.none) + #expect(white > 0.3, "Sampling the white half must coat the sphere (\(white)).") + #expect(Self.matches(baseline, black), "Sampling the black half must leave it uncoated.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func importedLayerTexturesReachTheMaterial() async throws { + _ = try await Render3DDebugViewGPUTests.makeScene() + // A 1x1 PNG. + let png = try #require(Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")) + let source = GLTFImportResult.Material( + name: nil, + baseColorFactor: .one, + baseColorTextureIndex: nil, + metallicFactor: 0, + roughnessFactor: 0.5, + metallicRoughnessTextureIndex: nil, + normalTextureIndex: nil, + clearcoatFactor: 1, + layerTextures: GLTFImportResult.LayerTextures( + clearcoat: .init(index: 0), + clearcoatNormal: .init(index: 0, texCoord: 1, transform: .init(scale: Vector2(2, 2)), scale: 0.5), + specularColor: .init(index: 0) + ) + ) + let result = GLTFImportResult( + nodes: [], meshes: [], materials: [source], + textures: [.init(source: 0, sampler: nil)], + images: [.init(uri: nil, data: png, mimeType: "image/png")], + scenes: [], defaultScene: nil + ) + let material = try #require(ModelAsset3D.makeMaterials(result).first as? PBRMaterial) + #expect(Set(material.layerTextures.keys) == [.clearcoat, .clearcoatNormal, .specularColor]) + let normal = try #require(material.layerTextures[.clearcoatNormal]) + #expect(normal.texCoord == 1 && normal.scale == 0.5 && normal.transform.scale == Vector2(2, 2)) + #expect(material.shaderFeatures == [.clearcoat, PBRLayerTexture.clearcoat.feature, PBRLayerTexture.clearcoatNormal.feature, PBRLayerTexture.specularColor.feature]) + } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) func clearcoatMatchesItsGoldenFrame() async throws { let (scene, _) = try await Self.makeScene { From 8ce3fab1a9cb1c07a24e93e44053350a5a74cd81 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 12:47:52 +0300 Subject: [PATCH 07/11] glTF: import morph targets, their weights and names, and animated weights Primitives carry their morph targets (POSITION, NORMAL and TANGENT deltas per vertex; sparse accessors expand to dense), meshes carry default weights and the extras.targetNames convention, nodes may override the weights, and animation channels with the weights path import with one value per target and key (three per key for cubic splines), also on matrix-placed nodes. Inconsistent data (weights or names that do not match the targets, primitives with different target counts, accessors of the wrong size, a weights channel on a node whose mesh has no targets) fails with invalidMorphTargets, invalidNode or invalidAnimation. Morphed attributes other than the three deltas are reported as warnings. This is data only: weights channels are not yet played (ModelAsset3D skips them) and nothing is rendered with the targets yet. Co-Authored-By: Claude Sonnet 5.5 --- Sources/AdaAssets/GLTF/GLTFLoader.swift | 54 +++- Sources/AdaAssets/GLTF/GLTFSkeletalData.swift | 2 + .../GLTF/NativeGLTFLoader+Animations.swift | 13 +- .../GLTF/NativeGLTFLoader+Morph.swift | 77 ++++++ .../GLTF/NativeGLTFLoader+Schema.swift | 11 + .../GLTF/NativeGLTFLoader+Skins.swift | 9 +- Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 16 +- .../AdaScene/3D/ModelAsset3D+Animation.swift | 4 +- .../GLTFMorphTargetImportTests.swift | 231 ++++++++++++++++++ .../GLTFSkeletalImportTests.swift | 5 +- 10 files changed, 406 insertions(+), 16 deletions(-) create mode 100644 Sources/AdaAssets/GLTF/NativeGLTFLoader+Morph.swift create mode 100644 Tests/AdaAssetsTests/GLTFMorphTargetImportTests.swift diff --git a/Sources/AdaAssets/GLTF/GLTFLoader.swift b/Sources/AdaAssets/GLTF/GLTFLoader.swift index 18abc26b9..34c81d1e0 100644 --- a/Sources/AdaAssets/GLTF/GLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/GLTFLoader.swift @@ -24,8 +24,19 @@ public struct GLTFImportResult: Sendable { public let skinIndex: Int? /// Authored local TRS; nil for nodes authored with a matrix. public let restPose: NodeTRS? - - public init(name: String?, transform: Transform3D, children: [Int], meshIndex: Int?, skinIndex: Int? = nil, restPose: NodeTRS? = nil) { + /// Morph target weights of this node's mesh instance; they replace the mesh's default weights. + public let weights: [Float]? + + public init( + name: String?, + transform: Transform3D, + children: [Int], + meshIndex: Int?, + skinIndex: Int? = nil, + restPose: NodeTRS? = nil, + weights: [Float]? = nil + ) { + self.weights = weights self.name = name self.transform = transform self.children = children @@ -35,13 +46,36 @@ public struct GLTFImportResult: Sendable { } } + /// Per-vertex displacement of one morph target of a primitive. A target that leaves an attribute alone has an empty + /// array for it; otherwise each array has one entry per vertex. Tangent deltas are directions only (xyz). + public struct MorphTarget: Sendable, Equatable { + public let positionDeltas: [Vector3] + public let normalDeltas: [Vector3] + public let tangentDeltas: [Vector3] + + public init(positionDeltas: [Vector3] = [], normalDeltas: [Vector3] = [], tangentDeltas: [Vector3] = []) { + self.positionDeltas = positionDeltas + self.normalDeltas = normalDeltas + self.tangentDeltas = tangentDeltas + } + } + public struct Mesh: Sendable { public let name: String? public let primitives: [Primitive] + /// Default morph target weights, one per target; empty when the mesh has no morph targets. + public let defaultWeights: [Float] + /// Morph target names (from `extras.targetNames`); empty or one per target. + public let targetNames: [String] + + /// Number of morph targets every primitive of this mesh has. + public var morphTargetCount: Int { primitives.first?.morphTargets.count ?? 0 } - public init(name: String?, primitives: [Primitive]) { + public init(name: String?, primitives: [Primitive], defaultWeights: [Float] = [], targetNames: [String] = []) { self.name = name self.primitives = primitives + self.defaultWeights = defaultWeights + self.targetNames = targetNames } } @@ -51,8 +85,18 @@ public struct GLTFImportResult: Sendable { public let materialIndex: Int? public let mode: PrimitiveMode public let skinning: Skinning? - - public init(attributes: [Attribute: Accessor], indices: [UInt32]?, materialIndex: Int?, mode: PrimitiveMode, skinning: Skinning? = nil) { + /// Morph targets of this primitive, in target order. + public let morphTargets: [MorphTarget] + + public init( + attributes: [Attribute: Accessor], + indices: [UInt32]?, + materialIndex: Int?, + mode: PrimitiveMode, + skinning: Skinning? = nil, + morphTargets: [MorphTarget] = [] + ) { + self.morphTargets = morphTargets self.attributes = attributes self.indices = indices self.materialIndex = materialIndex diff --git a/Sources/AdaAssets/GLTF/GLTFSkeletalData.swift b/Sources/AdaAssets/GLTF/GLTFSkeletalData.swift index 0a8ef9432..56d88f63f 100644 --- a/Sources/AdaAssets/GLTF/GLTFSkeletalData.swift +++ b/Sources/AdaAssets/GLTF/GLTFSkeletalData.swift @@ -54,6 +54,8 @@ extension GLTFImportResult { case translation case rotation case scale + /// Morph target weights of the node's mesh: one value per target for each key. + case weights } public struct AnimationSampler: Sendable { diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Animations.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Animations.swift index ab00a452d..f9a678167 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Animations.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Animations.swift @@ -33,7 +33,8 @@ extension NativeGLTFLoader { guard let path = GLTFImportResult.AnimationPath(rawValue: channel.target.path) else { throw GLTFError.unsupportedAnimationPath(channel.target.path) } - guard let node = channel.target.node, nodes.indices.contains(node), nodes[node].matrix == nil, + // Weights animate a mesh's morph targets, so they are valid on a node that is placed by a matrix too. + guard let node = channel.target.node, nodes.indices.contains(node), path == .weights || nodes[node].matrix == nil, samplers.indices.contains(channel.sampler), targets.insert("\(node):\(path.rawValue)").inserted else { throw GLTFError.invalidAnimation(index) @@ -41,8 +42,14 @@ extension NativeGLTFLoader { let sampler = samplers[channel.sampler] let output = try checkedAccessor(animation.samplers[channel.sampler].output, gltf: gltf) let isCubic = sampler.interpolation == .cubicSpline - guard output.type == (path == .rotation ? "VEC4" : "VEC3"), - sampler.output.count == sampler.times.count * (isCubic ? 3 : 1), + // A weights sampler holds one value per target and key (three of each per key for cubic splines). + let valuesPerKey = path == .weights ? morphTargetCount(ofNode: nodes[node], in: gltf) : 1 + guard path != .weights || valuesPerKey > 0 else { + throw GLTFError.invalidAnimation(index) + } + let expectedType = path == .rotation ? "VEC4" : (path == .weights ? "SCALAR" : "VEC3") + guard output.type == expectedType, + sampler.output.count == sampler.times.count * valuesPerKey * (isCubic ? 3 : 1), !isCubic || sampler.times.count >= 2 else { throw GLTFError.invalidAnimation(index) diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Morph.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Morph.swift new file mode 100644 index 000000000..90cd2709f --- /dev/null +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Morph.swift @@ -0,0 +1,77 @@ +import Foundation +import Math + +extension NativeGLTFLoader { + /// Reads the morph targets of one primitive. Each target lists the attributes it displaces (POSITION, NORMAL, + /// TANGENT); every accessor must be a float-vector per vertex of the primitive. Other morphed attributes are + /// ignored and reported by `warnings(for:)`. + func importMorphTargets( + _ primitive: GLTF.Mesh.Primitive, + attributes: [GLTFImportResult.Attribute: GLTFImportResult.Accessor], + gltf: GLTF, + buffers: [Data], + meshIndex: Int + ) throws -> [GLTFImportResult.MorphTarget] { + guard let targets = primitive.targets, !targets.isEmpty else { + return [] + } + guard let positions = attributes[.position] else { + throw GLTFError.invalidMorphTargets(meshIndex) + } + func deltas(_ name: String, in target: [String: Int]) throws -> [Vector3] { + guard let accessorIndex = target[name] else { return [] } + let accessor = try checkedAccessor(accessorIndex, gltf: gltf) + let decoded = try decodeAccessor(accessorIndex, gltf: gltf, buffers: buffers) + guard accessor.type == "VEC3", decoded.componentCount == 3, decoded.values.count == positions.count * 3, + decoded.values.allSatisfy({ $0.isFinite }) + else { + throw GLTFError.invalidMorphTargets(meshIndex) + } + var vectors: [Vector3] = [] + vectors.reserveCapacity(positions.count) + for offset in stride(from: 0, to: decoded.values.count, by: 3) { + let x = Float(decoded.values[offset]), y = Float(decoded.values[offset + 1]), z = Float(decoded.values[offset + 2]) + vectors.append(Vector3(x, y, z)) + } + return vectors + } + return try targets.map { target in + GLTFImportResult.MorphTarget( + positionDeltas: try deltas("POSITION", in: target), + normalDeltas: try deltas("NORMAL", in: target), + tangentDeltas: try deltas("TANGENT", in: target) + ) + } + } + + /// Checks that every primitive of a mesh has the same number of targets and that the default weights and names match. + func importMorphMetadata( + _ mesh: GLTF.Mesh, + primitives: [GLTFImportResult.Primitive], + meshIndex: Int + ) throws -> (weights: [Float], names: [String]) { + let count = primitives.first?.morphTargets.count ?? 0 + guard primitives.allSatisfy({ $0.morphTargets.count == count }) else { + throw GLTFError.invalidMorphTargets(meshIndex) + } + guard count > 0 else { + // Weights or names without targets mean nothing; an authored non-empty list is an error. + if mesh.weights?.isEmpty == false { throw GLTFError.invalidMorphTargets(meshIndex) } + return ([], []) + } + let weights = mesh.weights ?? [Float](repeating: 0, count: count) + let names = mesh.extras?.targetNames ?? [] + guard weights.count == count, weights.allSatisfy(\.isFinite), names.isEmpty || names.count == count else { + throw GLTFError.invalidMorphTargets(meshIndex) + } + return (weights, names) + } + + /// The number of morph targets of the mesh a node instantiates. + func morphTargetCount(ofNode node: GLTF.Node, in gltf: GLTF) -> Int { + guard let meshIndex = node.mesh, let meshes = gltf.meshes, meshes.indices.contains(meshIndex) else { + return 0 + } + return meshes[meshIndex].primitives.first?.targets?.count ?? 0 + } +} diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift index cc3843265..42984b394 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Schema.swift @@ -9,6 +9,7 @@ extension NativeGLTFLoader { case invalidSkinningAttributes case unsupportedJointSet(Int) case invalidAnimation(Int) + case invalidMorphTargets(Int) case unsupportedAnimationPath(String) case invalidGLB case missingJSONChunk @@ -77,10 +78,19 @@ extension NativeGLTFLoader { let indices: Int? let material: Int? let mode: Int? + /// Morph targets: per target, the accessor of each displaced attribute (POSITION, NORMAL, TANGENT). + let targets: [[String: Int]]? + } + + /// Application data glTF leaves open; `targetNames` is the widely used convention for naming morph targets. + struct Extras: Codable { + let targetNames: [String]? } let name: String? let primitives: [Primitive] + let weights: [Float]? + let extras: Extras? } struct TextureExtensions: Codable { @@ -203,6 +213,7 @@ extension NativeGLTFLoader { let mesh: Int? let camera: Int? let skin: Int? + let weights: [Float]? } struct Skin: Codable { diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Skins.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Skins.swift index 564e75788..fa3af5017 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Skins.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Skins.swift @@ -55,13 +55,20 @@ extension NativeGLTFLoader { restPose = pose transform = pose.matrix } + if let weights = node.weights { + // Node weights override the mesh's; there must be one per target of the node's mesh. + guard weights.count == morphTargetCount(ofNode: node, in: gltf), !weights.isEmpty, weights.allSatisfy(\.isFinite) else { + throw GLTFError.invalidNode(index) + } + } return GLTFImportResult.Node( name: node.name, transform: transform, children: node.children ?? [], meshIndex: node.mesh, skinIndex: node.skin, - restPose: restPose + restPose: restPose, + weights: node.weights ) } } diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 43d4f3d2b..6c269f8f6 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -36,6 +36,12 @@ public struct NativeGLTFLoader: GLTFLoader { for name in (gltf.extensionsUsed ?? []).sorted() where !Self.supportedExtensions.contains(name) { result.append("The extension \(name) is not supported and was ignored.") } + for (index, mesh) in (gltf.meshes ?? []).enumerated() { + let ignored = Set(mesh.primitives.flatMap { $0.targets ?? [] }.flatMap(\.keys)).subtracting(["POSITION", "NORMAL", "TANGENT"]) + if !ignored.isEmpty { + result.append("Mesh \(index) morphs \(ignored.sorted().joined(separator: ", ")); only POSITION, NORMAL and TANGENT deltas were applied.") + } + } return result } @@ -163,8 +169,8 @@ public struct NativeGLTFLoader: GLTFLoader { } let materials = try importMaterials(gltf) - let meshes = try (gltf.meshes ?? []) - .map { mesh -> GLTFImportResult.Mesh in + let meshes = try (gltf.meshes ?? []).enumerated() + .map { meshIndex, mesh -> GLTFImportResult.Mesh in let primitives = try mesh.primitives.map { primitive -> GLTFImportResult.Primitive in var attributes = [GLTFImportResult.Attribute: GLTFImportResult.Accessor]() @@ -198,11 +204,13 @@ public struct NativeGLTFLoader: GLTFLoader { indices: indices, materialIndex: primitive.material, mode: GLTFImportResult.PrimitiveMode(rawValue: primitive.mode ?? 4) ?? .triangles, - skinning: importSkinning(primitive, attributes: attributes, gltf: gltf) + skinning: importSkinning(primitive, attributes: attributes, gltf: gltf), + morphTargets: importMorphTargets(primitive, attributes: attributes, gltf: gltf, buffers: buffers, meshIndex: meshIndex) ) } - return GLTFImportResult.Mesh(name: mesh.name, primitives: primitives) + let morph = try importMorphMetadata(mesh, primitives: primitives, meshIndex: meshIndex) + return GLTFImportResult.Mesh(name: mesh.name, primitives: primitives, defaultWeights: morph.weights, targetNames: morph.names) } let nodes = try importNodes(gltf) diff --git a/Sources/AdaScene/3D/ModelAsset3D+Animation.swift b/Sources/AdaScene/3D/ModelAsset3D+Animation.swift index 450b0084c..2165d2afb 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Animation.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Animation.swift @@ -19,13 +19,15 @@ extension ModelAsset3D { static func makeClips(_ clips: [GLTFImportResult.Animation]) throws -> [SkeletalAnimationClip] { try clips.enumerated().map { index, clip in - let tracks = try clip.channels.map { channel in + // Morph weight channels are not pose tracks; they are played by the morph runtime, not by skeletal poses. + let tracks = try clip.channels.filter { $0.path != .weights }.map { channel in let sampler = clip.samplers[channel.samplerIndex] let path: SkeletalAnimationTrack.Path switch channel.path { case .translation: path = .translation case .rotation: path = .rotation case .scale: path = .scale + case .weights: preconditionFailure("Weight channels are filtered out above.") } let interpolation: SkeletalAnimationTrack.Interpolation switch sampler.interpolation { diff --git a/Tests/AdaAssetsTests/GLTFMorphTargetImportTests.swift b/Tests/AdaAssetsTests/GLTFMorphTargetImportTests.swift new file mode 100644 index 000000000..afc6e475f --- /dev/null +++ b/Tests/AdaAssetsTests/GLTFMorphTargetImportTests.swift @@ -0,0 +1,231 @@ +import AdaAssets +import Foundation +import Math +import Testing + +/// A one-triangle glTF with two morph targets, built from a data URI so every case is a few edits of the JSON. +private struct MorphGLTFFixture { + var meshWeights: [Float]? = [0.25, 0.75] + var targetNames: [String]? = ["smile", "blink"] + var nodeWeights: [Float]? + var nodeMatrix: [Float]? + var secondPrimitiveTargetCount: Int? + var targetAttributes: [[String: Int]]? + var sparseFirstTarget = false + var animation: [String: Any]? + + // Layout: 0 positions, 1 target 0 positions, 2 target 1 positions, 3 target 1 normals, 4 times, 5 weights (2 keys x 2 targets), + // 6 sparse indices, 7 sparse values. + static let positions: [Float] = [0, 0, 0, 1, 0, 0, 0, 1, 0] + static let target0: [Float] = [0, 0.5, 0, 0, 0.5, 0, 0, 0.5, 0] + static let target1: [Float] = [1, 0, 0, 1, 0, 0, 1, 0, 0] + static let target1Normals: [Float] = [0, 0, 0.1, 0, 0, 0.1, 0, 0, 0.1] + static let times: [Float] = [0, 1] + static let weightKeys: [Float] = [0, 1, 1, 0] + + func data() throws -> Data { + var bytes = Data() + var views: [[String: Any]] = [] + func add(_ floats: [Float]) { + var chunk = Data() + for value in floats { withUnsafeBytes(of: value.bitPattern.littleEndian) { chunk.append(contentsOf: $0) } } + views.append(["buffer": 0, "byteOffset": bytes.count, "byteLength": chunk.count]) + bytes.append(chunk) + } + add(Self.positions) + add(Self.target0) + add(Self.target1) + add(Self.target1Normals) + add(Self.times) + add(animation?["values"] as? [Float] ?? Self.weightKeys) + // Sparse: vertex 1 of the first target is displaced by (0, 2, 0); the rest stays zero. + var indices = Data() + withUnsafeBytes(of: UInt16(1).littleEndian) { indices.append(contentsOf: $0) } + views.append(["buffer": 0, "byteOffset": bytes.count, "byteLength": 2]) + bytes.append(indices) + while bytes.count % 4 != 0 { bytes.append(0) } + add([0, 2, 0]) + + func accessor(_ view: Int, _ count: Int, _ type: String) -> [String: Any] { + ["bufferView": view, "componentType": 5126, "count": count, "type": type] + } + var accessors: [[String: Any]] = [ + accessor(0, 3, "VEC3"), accessor(1, 3, "VEC3"), accessor(2, 3, "VEC3"), accessor(3, 3, "VEC3"), + accessor(4, 2, "SCALAR"), accessor(5, animation?["count"] as? Int ?? 4, "SCALAR"), + [ + "componentType": 5126, "count": 3, "type": "VEC3", + "sparse": ["count": 1, "indices": ["bufferView": 6, "componentType": 5123], "values": ["bufferView": 7]], + ], + ] + accessors[0]["min"] = [0, 0, 0] + accessors[0]["max"] = [1, 1, 0] + + let targets = targetAttributes ?? [ + ["POSITION": sparseFirstTarget ? 6 : 1], + ["POSITION": 2, "NORMAL": 3], + ] + var primitive: [String: Any] = ["attributes": ["POSITION": 0], "targets": targets] + var primitives: [[String: Any]] = [primitive] + if let count = secondPrimitiveTargetCount { + primitive["targets"] = Array(repeating: ["POSITION": 1], count: count) + primitives.append(primitive) + } + var mesh: [String: Any] = ["primitives": primitives] + if let meshWeights { mesh["weights"] = meshWeights } + if let targetNames { mesh["extras"] = ["targetNames": targetNames] } + var node: [String: Any] = ["mesh": 0] + if let nodeWeights { node["weights"] = nodeWeights } + if let nodeMatrix { node["matrix"] = nodeMatrix } + + var document: [String: Any] = [ + "asset": ["version": "2.0"], + "buffers": [["byteLength": bytes.count, "uri": "data:application/octet-stream;base64," + bytes.base64EncodedString()]], + "bufferViews": views, + "accessors": accessors, + "meshes": [mesh], + "nodes": [node], + "scenes": [["nodes": [0]]], + ] + if let animation { + document["animations"] = [[ + "samplers": [["input": 4, "output": 5, "interpolation": animation["interpolation"] as? String ?? "LINEAR"]], + "channels": [["sampler": 0, "target": ["node": animation["node"] as? Int ?? 0, "path": "weights"]]], + ]] + } + return try JSONSerialization.data(withJSONObject: document) + } +} + +@Suite +struct GLTFMorphTargetImportTests { + @Test + func importsTargetsWeightsAndNames() throws { + let result = try NativeGLTFLoader().load(data: MorphGLTFFixture().data()) + let mesh = try #require(result.meshes.first) + #expect(mesh.morphTargetCount == 2) + #expect(mesh.defaultWeights == [0.25, 0.75] && mesh.targetNames == ["smile", "blink"]) + let targets = try #require(mesh.primitives.first).morphTargets + #expect(targets[0].positionDeltas == [Vector3(0, 0.5, 0), Vector3(0, 0.5, 0), Vector3(0, 0.5, 0)]) + #expect(targets[0].normalDeltas.isEmpty && targets[0].tangentDeltas.isEmpty, "A target without NORMAL leaves normals alone.") + #expect(targets[1].positionDeltas.count == 3 && targets[1].positionDeltas[0] == Vector3(1, 0, 0)) + #expect(targets[1].normalDeltas.first == Vector3(0, 0, 0.1)) + #expect(result.warnings.isEmpty) + } + + @Test + func meshesWithoutTargetsHaveNoMorphData() throws { + var fixture = MorphGLTFFixture() + fixture.targetAttributes = [] + fixture.meshWeights = nil + fixture.targetNames = nil + let result = try NativeGLTFLoader().load(data: fixture.data()) + #expect(result.meshes[0].morphTargetCount == 0 && result.meshes[0].defaultWeights.isEmpty) + #expect(result.meshes[0].primitives[0].morphTargets.isEmpty) + } + + @Test + func missingWeightsDefaultToZero() throws { + var fixture = MorphGLTFFixture() + fixture.meshWeights = nil + fixture.targetNames = nil + let mesh = try #require(NativeGLTFLoader().load(data: fixture.data()).meshes.first) + #expect(mesh.defaultWeights == [0, 0] && mesh.targetNames.isEmpty) + } + + @Test + func sparseTargetAccessorsExpandToDenseDeltas() throws { + var fixture = MorphGLTFFixture() + fixture.sparseFirstTarget = true + let target = try #require(NativeGLTFLoader().load(data: fixture.data()).meshes[0].primitives[0].morphTargets.first) + #expect(target.positionDeltas == [.zero, Vector3(0, 2, 0), .zero]) + } + + @Test + func nodeWeightsOverrideTheMeshWeights() throws { + var fixture = MorphGLTFFixture() + fixture.nodeWeights = [1, 0] + let result = try NativeGLTFLoader().load(data: fixture.data()) + #expect(result.nodes[0].weights == [1, 0] && result.meshes[0].defaultWeights == [0.25, 0.75]) + #expect(try NativeGLTFLoader().load(data: MorphGLTFFixture().data()).nodes[0].weights == nil) + } + + @Test + func rejectsNodeWeightsThatDoNotMatchTheTargets() throws { + var fixture = MorphGLTFFixture() + fixture.nodeWeights = [1] + let data = try fixture.data() + #expect(throws: NativeGLTFLoader.GLTFError.invalidNode(0)) { try NativeGLTFLoader().load(data: data) } + } + + @Test(arguments: ["meshWeights", "names", "primitiveCount", "accessorCount", "withoutPositions"]) + func rejectsInconsistentMorphData(kind: String) throws { + var fixture = MorphGLTFFixture() + switch kind { + case "meshWeights": fixture.meshWeights = [1, 2, 3] + case "names": fixture.targetNames = ["only one"] + case "primitiveCount": fixture.secondPrimitiveTargetCount = 1 + case "accessorCount": fixture.targetAttributes = [["POSITION": 4]] + default: break + } + var data = try fixture.data() + if kind == "withoutPositions" { + var document = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var meshes = try #require(document["meshes"] as? [[String: Any]]) + var primitives = try #require(meshes[0]["primitives"] as? [[String: Any]]) + primitives[0]["attributes"] = ["NORMAL": 3] + meshes[0]["primitives"] = primitives + document["meshes"] = meshes + data = try JSONSerialization.data(withJSONObject: document) + } + let loaded = data + #expect(throws: NativeGLTFLoader.GLTFError.invalidMorphTargets(0)) { try NativeGLTFLoader().load(data: loaded) } + } + + @Test + func morphedAttributesOtherThanPositionNormalAndTangentAreReported() throws { + var fixture = MorphGLTFFixture() + fixture.targetAttributes = [["POSITION": 1, "TEXCOORD_0": 1], ["POSITION": 2]] + let result = try NativeGLTFLoader().load(data: fixture.data()) + #expect(result.warnings.count == 1 && result.warnings[0].contains("TEXCOORD_0") && result.warnings[0].contains("Mesh 0")) + #expect(result.meshes[0].primitives[0].morphTargets[0].positionDeltas.count == 3) + } + + @Test(arguments: ["LINEAR", "STEP"]) + func importsAnimatedWeights(interpolation: String) throws { + var fixture = MorphGLTFFixture() + fixture.animation = ["interpolation": interpolation] + let result = try NativeGLTFLoader().load(data: fixture.data()) + let animation = try #require(result.animations.first) + #expect(animation.channels[0].path == .weights && animation.channels[0].nodeIndex == 0) + #expect(animation.samplers[0].times == [0, 1]) + #expect(animation.samplers[0].output.values == [0, 1, 1, 0], "Two keys of two target weights.") + #expect(animation.samplers[0].interpolation.rawValue == interpolation) + } + + @Test + func cubicWeightSamplersCarryTangents() throws { + var fixture = MorphGLTFFixture() + let values = [Float](repeating: 0.5, count: 12) + fixture.animation = ["interpolation": "CUBICSPLINE", "values": values, "count": 12] + let sampler = try #require(NativeGLTFLoader().load(data: fixture.data()).animations.first?.samplers.first) + #expect(sampler.output.values.count == 12, "In-tangent, value and out-tangent for each of two targets at each of two keys.") + } + + @Test + func weightChannelsAreValidOnMatrixNodesButNeedTargets() throws { + var fixture = MorphGLTFFixture() + fixture.animation = [:] + fixture.nodeMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + #expect(try NativeGLTFLoader().load(data: fixture.data()).animations.count == 1) + fixture.animation = ["count": 6, "values": [Float](repeating: 0, count: 6)] + let wrongCount = try fixture.data() + #expect(throws: NativeGLTFLoader.GLTFError.invalidAnimation(0)) { try NativeGLTFLoader().load(data: wrongCount) } + var untargeted = MorphGLTFFixture() + untargeted.targetAttributes = [] + untargeted.meshWeights = nil + untargeted.targetNames = nil + untargeted.animation = [:] + let withoutTargets = try untargeted.data() + #expect(throws: NativeGLTFLoader.GLTFError.invalidAnimation(0)) { try NativeGLTFLoader().load(data: withoutTargets) } + } +} diff --git a/Tests/AdaAssetsTests/GLTFSkeletalImportTests.swift b/Tests/AdaAssetsTests/GLTFSkeletalImportTests.swift index da2fe3143..f9da54aeb 100644 --- a/Tests/AdaAssetsTests/GLTFSkeletalImportTests.swift +++ b/Tests/AdaAssetsTests/GLTFSkeletalImportTests.swift @@ -173,11 +173,12 @@ struct GLTFSkeletalImportTests { } @Test - func unsupportedMorphAnimationFailsExplicitly() throws { + func weightsChannelOnANodeWithoutMorphTargetsIsInvalid() throws { + // Weights animation is supported (see GLTFMorphTargetImportTests), but this node's mesh has no targets to weigh. var fixture = SkeletalGLTFFixture() fixture.channels[0]["target"] = ["node": 2, "path": "weights"] let data = try fixture.data() - #expect(throws: NativeGLTFLoader.GLTFError.unsupportedAnimationPath("weights")) { try NativeGLTFLoader().load(data: data) } + #expect(throws: NativeGLTFLoader.GLTFError.invalidAnimation(0)) { try NativeGLTFLoader().load(data: data) } } @Test From 991044b0a6cb9f3918e35b889bf0d70f5da32f51 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 13:31:29 +0300 Subject: [PATCH 08/11] 3D: morph targets evaluated on the GPU, with skinning, shadows, motion vectors and bounds Each mesh part packs its morph target deltas into one half-float texture; a draw applies the eight targets with the largest weights, fetched by the vertex shader (Morph.glsl, behind ADA_MORPH) before skinning, so unmorphed meshes keep the plain shaders. The weights live in a per-entity, per-part uniform with the previous frame's targets for motion vectors. The morph feature selects vertex shader variants of the main, motion, directional shadow, local shadow and occlusion prepass shaders (Shadow3DConfiguration joins the shadow pipeline key). Source bounds grow by the displacement the active weights can cause (scaled by the largest joint scale for skinned meshes); a morphed mesh is its own batch, has no LOD alternatives, and counts as dynamic for the shadow face cache. MorphWeights3DComponent holds the weights and ModelAsset3D instantiates it with the file's defaults. Tests compare a weighted shift with moving the entity (sun and local-light shadows included), the eight-target limit, culling of a mesh that rests off screen, normal deltas, the occlusion prepass, the temporal motion pass, previous-frame weights, and morph-before-skin on a glTF built in the test. Records the design and limits in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 43 ++- .../Assets/Shaders/directional_shadow_3d.glsl | 11 +- .../Assets/Shaders/flat3d.glsl | 17 +- .../Assets/Shaders/motion_3d.glsl | 14 +- .../CorePipelines/GPUVisibility3D.swift | 9 +- .../CorePipelines/MeshVisibility3D.swift | 11 +- .../CorePipelines/Morph3DUniforms.swift | 126 ++++++ .../DirectionalShadow3DRenderNode.swift | 9 +- .../LocalShadow3DRenderNode.swift | 13 +- .../DirectionalShadow3DPipeline.swift | 26 +- .../RenderPipelines3D/Flat3DPipeline.swift | 26 +- .../RenderPipelines3D/Motion3DPipeline.swift | 6 +- .../CorePipelines/ShadowSceneDigest3D.swift | 6 +- .../CorePipelines/VisibleMesh3DLists.swift | 7 +- .../Assets/Shaders/Public/Morph.glsl | 54 +++ Sources/AdaRender/Materials/PBRMaterial.swift | 5 + Sources/AdaRender/Mesh/Mesh.swift | 3 + Sources/AdaRender/Mesh/MorphTargetSet.swift | 191 +++++++++ .../RenderItems/Opaque3DRenderItem.swift | 6 +- Sources/AdaRender/Shaders/ShaderModule.swift | 6 +- .../Components/MorphWeights3DComponent.swift | 41 ++ Sources/AdaScene/3D/Model3DPlugin.swift | 43 ++- Sources/AdaScene/3D/ModelAsset3D.swift | 32 +- .../Render3DMorphTargetTests.swift | 363 ++++++++++++++++++ 24 files changed, 1030 insertions(+), 38 deletions(-) create mode 100644 Sources/AdaCorePipelines/CorePipelines/Morph3DUniforms.swift create mode 100644 Sources/AdaRender/Assets/Shaders/Public/Morph.glsl create mode 100644 Sources/AdaRender/Mesh/MorphTargetSet.swift create mode 100644 Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift create mode 100644 Tests/AdaEngineTests/Render3DMorphTargetTests.swift diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 988181593..07a2cf401 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -301,8 +301,9 @@ Each item needs its own short requirements note before implementation. - [x] Stage 2: clearcoat, clearcoat roughness and normal, sheen color and roughness, anisotropy and specular textures (see "Stage 2, fifth slice"). - [ ] Stage 2: `KHR_lights_punctual` import. - [x] Stage 2: unsupported-extension degradation policy and documented supported list (`NativeGLTFLoader.supportedExtensions`, `GLTFImportResult.warnings`). -- [ ] Stage 3: morph-target asset, glTF import and GPU evaluation. -- [ ] Stage 3: morph with skinning, shadows, motion vectors, bounds and animation weights. +- [x] Stage 3: morph-target asset, glTF import and GPU evaluation (see "Stage 3, first slice"). +- [x] Stage 3: morph with skinning, shadows, motion vectors and bounds. +- [ ] Stage 3: animated weights (clips and the animation graph), AdaScript and Editor exposure. - [ ] Stage 4: OIT mode with sorted fallback. - [ ] Stage 4: transmission/volume pass. - [x] Stage 5: distance/height fog (`Fog3D` in `Environment3D`, applied in the composite pass; see "Stage 5, first slice"). @@ -882,6 +883,44 @@ anisotropy texture of (1, 0.5, 1) equals the factor alone, and (0.5, 1, 1) equal specular factor of zero; a flat clearcoat normal changes nothing) and a transform test that samples either half of a two-tone mask. Each of the shader reads was checked by removing it and watching its test fail. +### Stage 3, first slice: morph targets (2026-10-19) + +**Import.** Primitives carry `targets` (POSITION, NORMAL and TANGENT deltas per vertex; sparse accessors expand to dense), meshes carry +the default `weights` and the `extras.targetNames` convention, nodes may override the weights, and animation channels with the +`weights` path import with one value per target and key. Inconsistent data fails with `invalidMorphTargets`; morphed +attributes other than the three deltas are reported as warnings. + +**GPU evaluation.** `MorphTargetSet` packs every target's deltas into one RGBA16F texture (`Part.morphTargets`): texel +`(target * vertices + vertex) * attributes + attribute`, 2048 texels wide, up to 8192 rows (about 16.7 million texels of +targets x vertices x attributes; a mesh past that fails to load with an explicit error). A draw applies at most 8 targets, the +ones with the largest weights (`MorphTargetSet.activeTargets`), and its vertex shader fetches only those deltas with `texelFetch` +(`Morph.glsl`, behind `ADA_MORPH`, so unmorphed meshes keep the plain shaders), so cost follows the active targets and memory follows all +of them. Deltas are half precision (portable bit conversion, no `Float16`). The weights live in `Morph3DUniforms`, one buffer per entity +and mesh part (parts differ in vertex count), holding the current and the previous frame's active targets. + +**Passes.** The morph feature (`PBRMaterial.ShaderFeatures.morph`, a property of the draw, not the material) selects a vertex +shader variant of the main shader, the motion shader and the directional and local shadow shaders (`Shadow3DConfiguration` joins the +pipeline key) and the occlusion prepass; weights and the delta texture bind at 21 and 40. Order is morph, then skin (the +deltas move the rest pose before the joint matrices). The motion pass moves a vertex from where the previous weights put it. + +**Culling, batching and caches.** The source bounds grow by the most the active weights can displace (the largest delta of each +active target times its weight, times the largest joint scale for skinned meshes), so frustum culling, shadow culling and the occlusion +candidates see the morphed shape. A morphed mesh is its own batch (like a skinned one), has no level-of-detail +alternatives, and counts as dynamic for the shadow face cache. `MorphWeights3DComponent` holds the weights (with `setWeight(_:named:)`), +and `ModelAsset3D.instantiate` adds it with the file's default weights. + +**Tests.** Import (`GLTFMorphTargetImportTests`, 11 cases); packing, selection and uniform layout (`Render3DMorphTargetSelectionTests`); +and GPU tests (`Render3DGPUTests/MorphTargets`): weight zero equals the unmorphed mesh, a weighted shift equals moving the entity (sun and +local-light shadows included), only the heaviest eight targets apply, weight changes update the shadow faces on the first frame, a mesh +resting off screen is not culled once morphed, normal deltas change shading but not the silhouette, the occlusion prepass and the +temporal motion pass draw the morphed shape, the uniform keeps the previous frame's weights, and a glTF skinned to a joint turned a quarter turn +applies its morph before the skin. Each shader read was checked by breaking it and watching a test fail. + +Limits: deltas are dense and half precision (no sparse or compacted storage, so a face with many targets costs texture memory +in proportion to targets x vertices); the selection is made per frame on the CPU; only POSITION, NORMAL and TANGENT morph; layered materials +and morph share the shader-variant mechanism, so the first draw of a morphed mesh compiles its variants (about a second each), and there is no web +build. Not done: playing `weights` channels (clips and the animation graph), the Editor and AdaScript. + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl index 066aa3894..0b50a0106 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.glsl @@ -1,5 +1,10 @@ #version 450 core #pragma stage : vert +#ifdef ADA_MORPH +#extension GL_EXT_samplerless_texture_functions : require +#endif + +#include layout (location = 13) in vec4 a_JointIndices; layout (location = 14) in vec4 a_JointWeights; @@ -39,13 +44,17 @@ layout (binding = 2) uniform DirectionalShadowViewUniform { [[main]] void directional_shadow_3d_vertex() { + vec3 localPosition = a_Position; +#ifdef ADA_MORPH + applyMorphPosition(localPosition); +#endif mat4 model = mat4(a_Model0, a_Model1, a_Model2, a_Model3) * skinTransform(); v_Fade = a_ShadowFlags.w; v_TileBounds = u_TileBounds; v_UV0 = a_TextureCoordinate; v_UV1 = a_TextureCoordinate1; v_Alpha = a_Color.a * a_VertexColor.a; - gl_Position = u_ShadowViewProjection * model * vec4(a_Position, 1.0); + gl_Position = u_ShadowViewProjection * model * vec4(localPosition, 1.0); } #version 450 core diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index 093ae7275..4faf15f5c 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -1,5 +1,8 @@ #version 450 core #pragma stage : vert +#ifdef ADA_MORPH +#extension GL_EXT_samplerless_texture_functions : require +#endif #ifdef ADA_METAL_RENDER_LAYER #extension GL_ARB_shader_viewport_layer_array : require @@ -9,6 +12,7 @@ layout (binding = 30) uniform RenderTargetLayer { #endif #include +#include layout (binding = 1) uniform DirectionalLight3DUniform { vec4 u_LightDirectionIntensity; @@ -75,13 +79,20 @@ void flat3d_vertex() { #ifdef ADA_METAL_RENDER_LAYER gl_Layer = int(u_RenderTargetLayer); +#endif + // Morph targets move the rest pose first; skinning then moves the morphed pose. + vec3 localPosition = a_Position; + vec3 localNormal = a_Normal; + vec3 localTangent = a_Tangent.xyz; +#ifdef ADA_MORPH + applyMorph(localPosition, localNormal, localTangent); #endif mat4 model = mat4(a_Model0, a_Model1, a_Model2, a_Model3) * skinTransform(); mat3 basis = mat3(model); mat3 normalMatrix = abs(determinant(basis)) > 0.000001 ? transpose(inverse(basis)) : mat3(1.0); - vec3 normal = normalize(normalMatrix * a_Normal); - vec4 worldPosition = model * vec4(a_Position, 1.0); - vec3 worldTangent = normalize(mat3(model) * a_Tangent.xyz); + vec3 normal = normalize(normalMatrix * localNormal); + vec4 worldPosition = model * vec4(localPosition, 1.0); + vec3 worldTangent = normalize(mat3(model) * localTangent); Output.Color = a_Color * a_VertexColor; Output.WorldPosition = worldPosition.xyz; Output.ViewPosition = (u_ViewMatrix * worldPosition).xyz; diff --git a/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl index 198d476a4..1460a3341 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.glsl @@ -1,6 +1,10 @@ #version 450 core #pragma stage : vert +#ifdef ADA_MORPH +#extension GL_EXT_samplerless_texture_functions : require +#endif #include +#include layout (binding = 14) uniform Skinning3DUniform { mat4 u_Joints[128]; }; layout (binding = 24) uniform PreviousSkinning3DUniform { mat4 u_PreviousJoints[128]; }; layout (binding = 23) uniform Temporal3DUniform { @@ -42,8 +46,14 @@ void motion_vertex() { previousSkin = u_PreviousJoints[int(a_Joints.x)] * a_Weights.x + u_PreviousJoints[int(a_Joints.y)] * a_Weights.y + u_PreviousJoints[int(a_Joints.z)] * a_Weights.z + u_PreviousJoints[int(a_Joints.w)] * a_Weights.w; } - vec4 world = mat4(a_Model0, a_Model1, a_Model2, a_Model3) * skin * vec4(a_Position, 1.0); - vec4 previousWorld = mat4(a_PreviousModel0, a_PreviousModel1, a_PreviousModel2, a_PreviousModel3) * previousSkin * vec4(a_Position, 1.0); + vec3 localPosition = a_Position; + vec3 previousLocalPosition = a_Position; +#ifdef ADA_MORPH + applyMorphPosition(localPosition); + previousLocalPosition = previousMorphedPosition(a_Position); +#endif + vec4 world = mat4(a_Model0, a_Model1, a_Model2, a_Model3) * skin * vec4(localPosition, 1.0); + vec4 previousWorld = mat4(a_PreviousModel0, a_PreviousModel1, a_PreviousModel2, a_PreviousModel3) * previousSkin * vec4(previousLocalPosition, 1.0); v_CurrentClip = u_CurrentViewProjection * world; v_PreviousClip = u_Parameters.z > 0.5 ? v_CurrentClip : u_PreviousViewProjection * previousWorld; gl_Position = u_ViewProjection * world; diff --git a/Sources/AdaCorePipelines/CorePipelines/GPUVisibility3D.swift b/Sources/AdaCorePipelines/CorePipelines/GPUVisibility3D.swift index ea435efcd..08d79dd20 100644 --- a/Sources/AdaCorePipelines/CorePipelines/GPUVisibility3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/GPUVisibility3D.swift @@ -79,7 +79,14 @@ public struct GPUVisibility3DState: Resource { let material = materials.buffer(for: item.material) else { continue } let part = item.mesh.models[item.modelIndex].parts[item.partIndex] - pass.setRenderPipelineState(pipelines.pipeline(for: part.vertexDescriptor, device: device)) + let morphed = item.morphBuffer != nil && part.morphTargets != nil + pass.setRenderPipelineState(pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.vertexDescriptor, morph: morphed), device: device)) + if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { + pass.setResourceSet(.init(bindings: [ + .init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0)), + .init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture)), + ]), index: 0) + } pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) pass.setVertexBuffer(wrap(instances), offset: Int(range.lowerBound) * MemoryLayout.stride, slot: 3) pass.setVertexBuffer(defaults, offset: 0, slot: 4) diff --git a/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift b/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift index 6c62b9bb7..bbd736965 100644 --- a/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift @@ -49,6 +49,13 @@ public struct Mesh3DRenderSource: Sendable { public var castShadows: Bool public var receiveShadows: Bool public var skinningBuffer: (any UniformBuffer)? + /// Morph weight buffers by mesh part (see ``morphKey(model:part:)``); empty for a mesh without morph weights. + public var morphBuffers: [UInt64: any UniformBuffer] + + /// The key of a mesh part in ``morphBuffers``. + public static func morphKey(model: Int, part: Int) -> UInt64 { + UInt64(truncatingIfNeeded: model) << 32 | UInt64(truncatingIfNeeded: part) + } public init( entity: Entity.ID, @@ -66,8 +73,10 @@ public struct Mesh3DRenderSource: Sendable { castShadows: Bool = true, receiveShadows: Bool = true, skinningBuffer: (any UniformBuffer)? = nil, - previousTransform: Transform3D? = nil + previousTransform: Transform3D? = nil, + morphBuffers: [UInt64: any UniformBuffer] = [:] ) { + self.morphBuffers = morphBuffers self.previousTransform = previousTransform ?? transform self.drawPass = drawPass self.entity = entity diff --git a/Sources/AdaCorePipelines/CorePipelines/Morph3DUniforms.swift b/Sources/AdaCorePipelines/CorePipelines/Morph3DUniforms.swift new file mode 100644 index 000000000..30ee3ea58 --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/Morph3DUniforms.swift @@ -0,0 +1,126 @@ +import AdaECS +@_spi(Internal) import AdaRender +import Math + +/// Frame-buffered weights of the active morph targets, one uniform buffer per entity and mesh part. +/// +/// A part's vertex count and layout differ from its siblings', so each part gets its own buffer even though the weights belong to +/// the entity. The buffer holds the current and the previous frame's active targets, so the motion pass can move a vertex from +/// where the previous weights put it. +/// +/// Layout, as 18 `vec4`: `[0]` current target count, previous target count, vertices per target, attributes per vertex; `[1]` +/// attribute flags; `[2..<10]` current (target index, weight); `[10..<18]` previous (target index, weight). +public struct Morph3DUniforms: Resource { + /// The vertex-stage slot of the weights buffer. + public static let binding = 21 + /// The vertex-stage slot of the delta texture. + public static let textureBinding = 40 + static let vectorCount = 18 + + /// Identifies one part of an entity's mesh. + public struct PartKey: Hashable, Sendable { + public let entity: Entity.ID + public let model: Int + public let part: Int + } + + private struct Entry { + var buffers: [(any UniformBuffer)?] + var frame: Int + var previous: [(index: Int, weight: Float)] + } + + private var entries: [PartKey: Entry] = [:] + private var frame = 0 + private var currentIndex = 0 + private let bufferCount: Int + private var upload = [Vector4](repeating: .zero, count: vectorCount) + + public init() { + bufferCount = max(1, unsafe RenderEngine.configurations.maxFramesInFlight) + } + + public mutating func beginFrame() { + for (key, entry) in entries where entry.frame != frame { + entries.removeValue(forKey: key) + } + frame &+= 1 + currentIndex = (currentIndex + 1) % bufferCount + } + + /// Test hooks for the buffer layout, which need no ``MorphTargetSet`` and so no GPU. + static let vectorCountForTests = vectorCount + + static func packForTests( + current: [(index: Int, weight: Float)], + previous: [(index: Int, weight: Float)], + vertexCount: Int, + attributes: Int, + flags: Float, + into vectors: inout [Vector4] + ) { + pack(current: current, previous: previous, header: (vertexCount, attributes, flags), into: &vectors) + } + + /// The vector array a part's buffer holds for these weights; the previous list is the last frame's when there is one. + static func pack( + current: [(index: Int, weight: Float)], + previous: [(index: Int, weight: Float)], + set: MorphTargetSet, + into vectors: inout [Vector4] + ) { + pack(current: current, previous: previous, header: (set.vertexCount, set.attributesPerVertex, set.attributeFlags), into: &vectors) + } + + private static func pack( + current: [(index: Int, weight: Float)], + previous: [(index: Int, weight: Float)], + header: (vertexCount: Int, attributes: Int, flags: Float), + into vectors: inout [Vector4] + ) { + for index in vectors.indices { vectors[index] = .zero } + vectors[0] = Vector4(Float(current.count), Float(previous.count), Float(header.vertexCount), Float(header.attributes)) + vectors[1] = Vector4(header.flags, 0, 0, 0) + for (slot, active) in current.prefix(MorphTargetSet.maximumActiveTargets).enumerated() { + vectors[2 + slot] = Vector4(Float(active.index), active.weight, 0, 0) + } + for (slot, active) in previous.prefix(MorphTargetSet.maximumActiveTargets).enumerated() { + vectors[10 + slot] = Vector4(Float(active.index), active.weight, 0, 0) + } + } + + /// The buffers written for the current frame, for tests. + func debugBuffers() -> [any UniformBuffer] { + entries.values.compactMap { $0.frame == frame ? $0.buffers[currentIndex] : nil } + } + + /// Writes the buffers of every morphed part of `mesh` for an entity and returns them by part. + public mutating func write( + weights: [Float], + mesh: Mesh, + for entity: Entity.ID, + device: RenderDevice + ) -> [PartKey: any UniformBuffer] { + let current = MorphTargetSet.activeTargets(weights: weights) + var result: [PartKey: any UniformBuffer] = [:] + for (modelIndex, model) in mesh.models.enumerated() { + for (partIndex, part) in model.parts.enumerated() { + guard let set = part.morphTargets else { continue } + let key = PartKey(entity: entity, model: modelIndex, part: partIndex) + var entry = entries[key] ?? Entry(buffers: Array(repeating: nil, count: bufferCount), frame: frame, previous: current) + if entry.buffers[currentIndex] == nil { + entry.buffers[currentIndex] = device.createUniformBuffer(Vector4.self, count: Self.vectorCount, binding: Self.binding) + } + Self.pack(current: current, previous: entry.previous, set: set, into: &upload) + entry.buffers[currentIndex]?.setElements(&upload) + entry.previous = current + entry.frame = frame + entries[key] = entry + if let buffer = entry.buffers[currentIndex] { + result[key] = buffer + } + } + } + return result + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift index 313d201c9..a8708f3fe 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift @@ -211,7 +211,8 @@ public struct DirectionalShadow3DRenderNode: RenderNode { guard item.castShadows, (item.material as? PBRMaterial)?.alphaMode != .blend, let batchRange = item.batchRange else { continue } - let pipeline = pipelines.pipeline(for: part.vertexDescriptor, device: renderDevice.renderDevice) + let morphed = item.morphBuffer != nil && part.morphTargets != nil + let pipeline = pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.vertexDescriptor, morph: morphed), device: renderDevice.renderDevice) pass.setRenderPipelineState(pipeline) pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) if let material = materialUniforms.buffer(for: item.material) { @@ -227,6 +228,12 @@ public struct DirectionalShadow3DRenderNode: RenderNode { .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), ]), index: 0) } + if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { + pass.setResourceSet(RenderResourceSet(bindings: [ + .init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0)), + .init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture)), + ]), index: 0) + } if let defaults = buffers.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift index 51ed6afe5..62bf0bb7b 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift @@ -9,8 +9,10 @@ struct LocalShadow3DScratch: Resource { } struct LocalShadow3DPipeline: RenderPipelineConfigurator { + typealias Configuration = Shadow3DConfiguration + private let base = DirectionalShadow3DPipeline() - func configurate(with configuration: VertexDescriptor) -> RenderPipelineDescriptor { + func configurate(with configuration: Shadow3DConfiguration) -> RenderPipelineDescriptor { var descriptor = base.configurate(with: configuration) descriptor.debugName = "Local Shadow 3D" descriptor.colorAttachments = [.init(format: .rgba_16f)] @@ -227,7 +229,14 @@ public struct LocalShadow3DRenderNode: RenderNode { guard item.castShadows, let range = item.batchRange, (item.material as? PBRMaterial)?.alphaMode != .blend else { continue } let part = item.mesh.models[item.modelIndex].parts[item.partIndex] guard let material = materials.buffer(for: item.material), let palette = item.skinningBuffer ?? skinning.identityBuffer else { continue } - pass.setRenderPipelineState(pipelines.pipeline(for: part.vertexDescriptor, device: device.renderDevice)) + let morphed = item.morphBuffer != nil && part.morphTargets != nil + pass.setRenderPipelineState(pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.vertexDescriptor, morph: morphed), device: device.renderDevice)) + if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { + pass.setResourceSet(RenderResourceSet(bindings: [ + .init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0)), + .init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture)), + ]), index: 0) + } pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) pass.setVertexBuffer(instances, offset: Int(range.lowerBound) * MemoryLayout.stride, slot: 3) if let defaults = buffers.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift index 7f119381d..278c4ce06 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift @@ -2,16 +2,38 @@ import AdaAssets import AdaECS import AdaRender +/// What decides a shadow-pass pipeline: the part's vertex layout and whether its mesh has morph targets. +public struct Shadow3DConfiguration: Hashable, Sendable { + public let vertex: VertexDescriptor + public let morph: Bool + + public init(vertex: VertexDescriptor, morph: Bool = false) { + self.vertex = vertex + self.morph = morph + } +} + /// Pipeline that renders opaque mesh depth into the primary directional-light shadow map. public struct DirectionalShadow3DPipeline: RenderPipelineConfigurator { + public typealias Configuration = Shadow3DConfiguration + private let shader: AssetHandle + private let variants = PBR3DShaderVariants() public init() { self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/directional_shadow_3d.glsl", from: .adaModule) } - public func configurate(with configuration: VertexDescriptor) -> RenderPipelineDescriptor { - var configuration = configuration + /// The pipeline for a part without morph targets. + public func configurate(with vertex: VertexDescriptor) -> RenderPipelineDescriptor { + configurate(with: Shadow3DConfiguration(vertex: vertex)) + } + + public func configurate(with shadowConfiguration: Shadow3DConfiguration) -> RenderPipelineDescriptor { + let shader = shadowConfiguration.morph + ? variants.shader(at: "Shaders/directional_shadow_3d.glsl", for: .morph, fallback: self.shader) + : self.shader + var configuration = shadowConfiguration.vertex configureSkinningAttributes(&configuration) if !configuration.attributes.containsAttribute(by: MeshDescriptor.textureCoordinates.id.name) { configuration.attributes[2] = .attribute(.vector2, name: "defaultTextureCoordinate", bufferIndex: 4, offset: 0) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift index 0194c2b95..67816822e 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift @@ -223,26 +223,36 @@ public struct Flat3DPipeline: RenderPipelineConfigurator { } -/// Compiled main-pass shaders by the shading layers they include. A variant is compiled on first use and shared by every -/// pipeline that needs it; if it fails to compile, the unspecialized shader is used instead. +/// Compiled shader variants by file and the features they include. A variant is compiled on first use and shared by every +/// pipeline that needs it; if it fails to compile, the plain shader is used instead. final class PBR3DShaderVariants: @unchecked Sendable { + private struct Key: Hashable { + let path: String + let features: PBRMaterial.ShaderFeatures + } + private let lock = NSLock() - private var variants: [PBRMaterial.ShaderFeatures: AssetHandle] = [:] + private var variants: [Key: AssetHandle] = [:] - func shader(for features: PBRMaterial.ShaderFeatures, fallback: AssetHandle) -> AssetHandle { + func shader( + at path: String = "Shaders/flat3d.glsl", + for features: PBRMaterial.ShaderFeatures, + fallback: AssetHandle + ) -> AssetHandle { lock.lock() defer { lock.unlock() } - if let existing = variants[features] { + let key = Key(path: path, features: features) + if let existing = variants[key] { return existing } let variant: AssetHandle do { - variant = AssetHandle(try ShaderModule.createBundled(at: "Shaders/flat3d.glsl", from: .adaModule, defines: features.enablingMacros)) + variant = AssetHandle(try ShaderModule.createBundled(at: path, from: .adaModule, defines: features.enablingMacros)) } catch { - assertionFailure("Main shader variant \(features.rawValue) failed to compile: \(error)") + assertionFailure("Shader variant \(features.rawValue) of \(path) failed to compile: \(error)") variant = fallback } - variants[features] = variant + variants[key] = variant return variant } } diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift index 7d44c4996..ba7972111 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift @@ -12,10 +12,14 @@ public struct ActiveMotion3DPass: Resource { /// Motion and reactive mask share the main pass's instance data, UVs and alpha tests. public struct Motion3DPipeline: RenderPipelineConfigurator { private let base = Flat3DPipeline() + private let variants = PBR3DShaderVariants() public init() {} public func configurate(with configuration: PBR3DConfiguration) -> RenderPipelineDescriptor { - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/motion_3d.glsl") + let plain = CorePipelineShaders.loadRequiredBundled(at: "Shaders/motion_3d.glsl") + // Only the morph feature matters to the motion shader; the shading layers do not. + let morph = configuration.features.intersection(.morph) + let shader = morph.isEmpty ? plain : variants.shader(at: "Shaders/motion_3d.glsl", for: morph, fallback: plain) var descriptor = base.configurate(with: configuration.vertex) descriptor.vertex = shader.asset.requiredShader(for: .vertex) descriptor.fragment = shader.asset.getShader(for: .fragment) diff --git a/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift b/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift index d7646418f..add79eb60 100644 --- a/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/ShadowSceneDigest3D.swift @@ -4,13 +4,13 @@ import Math /// A fingerprint of every shadow caster in the extracted scene, used to skip rebuilding per-face draw lists when /// nothing a shadow face could draw has changed. enum ShadowSceneDigest3D { - /// The digest of the casters in `sources`, or `nil` when the scene cannot be summarized cheaply: a skinned caster - /// changes pose without any change visible here, and with level-of-detail on, which mesh a caster draws + /// The digest of the casters in `sources`, or `nil` when the scene cannot be summarized cheaply: a skinned or morphed + /// caster changes shape without any change visible here, and with level-of-detail on, which mesh a caster draws /// depends on the camera. static func compute(sources: [Mesh3DRenderSource], usesLOD: Bool) -> UInt64? { var digest = FrameDigest3D() for index in sources.indices where sources[index].castShadows { - if sources[index].skinningBuffer != nil || (usesLOD && !sources[index].alternatives.isEmpty) { + if sources[index].skinningBuffer != nil || !sources[index].morphBuffers.isEmpty || (usesLOD && !sources[index].alternatives.isEmpty) { return nil } digest.combine(sources[index].transform) diff --git a/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift b/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift index 401d9ed19..171811aed 100644 --- a/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift +++ b/Sources/AdaCorePipelines/CorePipelines/VisibleMesh3DLists.swift @@ -65,6 +65,7 @@ public struct VisibleMesh3DLists: Resource { var material: MaterialKey var descriptor: Int32 var skin: ObjectIdentifier? + var morph: ObjectIdentifier? var cast: Bool var receive: Bool } @@ -224,6 +225,7 @@ public struct VisibleMesh3DLists: Resource { material: materialKey, descriptor: Int32(descriptorID), skin: sources[sourceIndex].skinningBuffer.map(ObjectIdentifier.init), + morph: sources[sourceIndex].morphBuffers[Mesh3DRenderSource.morphKey(model: modelIndex, part: partIndex)].map(ObjectIdentifier.init), cast: sources[sourceIndex].castShadows, receive: sources[sourceIndex].receiveShadows ) @@ -288,7 +290,7 @@ public struct VisibleMesh3DLists: Resource { shadowFlags.w = 1 - record.fade if isShadowPass { // A skinned pose changes without any CPU-visible identity change, so such a face is never reusable. - if sources[sourceIndex].skinningBuffer != nil { entry.isDynamic = true } + if sources[sourceIndex].skinningBuffer != nil || !sources[sourceIndex].morphBuffers.isEmpty { entry.isDynamic = true } entry.digest.combine(template.batchHash) entry.digest.combine(sources[sourceIndex].transform) entry.digest.combine((sources[sourceIndex].materials[Int(record.material)] as? PBRMaterial)?.baseColorFactor ?? .one) @@ -321,7 +323,8 @@ public struct VisibleMesh3DLists: Resource { castShadows: sources[sourceIndex].castShadows, receiveShadows: sources[sourceIndex].receiveShadows, batchRange: instanceIndex..<(instanceIndex + 1), - skinningBuffer: sources[sourceIndex].skinningBuffer + skinningBuffer: sources[sourceIndex].skinningBuffer, + morphBuffer: sources[sourceIndex].morphBuffers[Mesh3DRenderSource.morphKey(model: Int(record.model), part: Int(record.part))] ) ) } diff --git a/Sources/AdaRender/Assets/Shaders/Public/Morph.glsl b/Sources/AdaRender/Assets/Shaders/Public/Morph.glsl new file mode 100644 index 000000000..1fe928f72 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/Public/Morph.glsl @@ -0,0 +1,54 @@ +// Morph targets (glTF blend shapes) applied to a vertex before skinning. +// +// Compiled in only for draws of morphed meshes, which define ADA_MORPH; otherwise this file declares nothing. The deltas of +// the few active targets are fetched from a half-float texture (see MorphTargetSet), so the cost depends on the number of +// active targets, not the number of targets the mesh has. +#ifdef ADA_MORPH +layout (binding = 21) uniform Morph3DUniform { + vec4 u_MorphCounts; // x active targets, y previous frame's active targets, z vertices per target, w attributes per vertex + vec4 u_MorphFlags; // x bit 0: normal deltas present, bit 1: tangent deltas present + vec4 u_MorphCurrent[8]; // x target index, y weight + vec4 u_MorphPrevious[8]; // the previous frame's, for motion vectors +}; +layout (binding = 40) uniform texture2D u_MorphDeltas; + +const int MORPH_TEXTURE_WIDTH = 2048; + +vec3 morphDelta(int morphTarget, int channel) { + int texel = (morphTarget * int(u_MorphCounts.z) + gl_VertexIndex) * int(u_MorphCounts.w) + channel; + return texelFetch(u_MorphDeltas, ivec2(texel % MORPH_TEXTURE_WIDTH, texel / MORPH_TEXTURE_WIDTH), 0).xyz; +} + +// Adds the weighted position deltas of the active targets. +void applyMorphPosition(inout vec3 position) { + for (int i = 0; i < 8; ++i) { + if (i >= int(u_MorphCounts.x)) { break; } + position += morphDelta(int(u_MorphCurrent[i].x), 0) * u_MorphCurrent[i].y; + } +} + +// The same for the previous frame's weights. +vec3 previousMorphedPosition(vec3 position) { + for (int i = 0; i < 8; ++i) { + if (i >= int(u_MorphCounts.y)) { break; } + position += morphDelta(int(u_MorphPrevious[i].x), 0) * u_MorphPrevious[i].y; + } + return position; +} + +// Adds the weighted position, normal and tangent deltas of the active targets. +void applyMorph(inout vec3 position, inout vec3 normal, inout vec3 tangent) { + int flags = int(u_MorphFlags.x); + bool hasNormals = (flags & 1) != 0; + bool hasTangents = (flags & 2) != 0; + int tangentChannel = hasNormals ? 2 : 1; + for (int i = 0; i < 8; ++i) { + if (i >= int(u_MorphCounts.x)) { break; } + int morphTarget = int(u_MorphCurrent[i].x); + float weight = u_MorphCurrent[i].y; + position += morphDelta(morphTarget, 0) * weight; + if (hasNormals) { normal += morphDelta(morphTarget, 1) * weight; } + if (hasTangents) { tangent += morphDelta(morphTarget, tangentChannel) * weight; } + } +} +#endif diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index 41c619cf0..ee145c7ee 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -163,6 +163,10 @@ public class PBRMaterial: Material, @unchecked Sendable { public static let sheen = ShaderFeatures(rawValue: 1 << 1) public static let anisotropy = ShaderFeatures(rawValue: 1 << 2) + /// The draw's mesh has morph targets. A property of the draw, not of the material, so ``PBRMaterial/shaderFeatures`` + /// never contains it. + public static let morph = ShaderFeatures(rawValue: 1 << 11) + /// Every layer, without textures. public static let all: ShaderFeatures = [.clearcoat, .sheen, .anisotropy] @@ -172,6 +176,7 @@ public class PBRMaterial: Material, @unchecked Sendable { if contains(.clearcoat) { macros.append("ADA_CLEARCOAT") } if contains(.sheen) { macros.append("ADA_SHEEN") } if contains(.anisotropy) { macros.append("ADA_ANISOTROPY") } + if contains(.morph) { macros.append("ADA_MORPH") } for texture in PBRLayerTexture.allCases where contains(texture.feature) { macros.append(texture.macro) } diff --git a/Sources/AdaRender/Mesh/Mesh.swift b/Sources/AdaRender/Mesh/Mesh.swift index 919338420..4d35b16ec 100644 --- a/Sources/AdaRender/Mesh/Mesh.swift +++ b/Sources/AdaRender/Mesh/Mesh.swift @@ -69,6 +69,9 @@ extension Mesh { public var indexCount: Int public var vertexBuffer: VertexBuffer + /// Morph targets of this part, if it has any. Weights are applied by the vertex shader before skinning. + public var morphTargets: MorphTargetSet? + @_spi(Internal) public init( id: Int, diff --git a/Sources/AdaRender/Mesh/MorphTargetSet.swift b/Sources/AdaRender/Mesh/MorphTargetSet.swift new file mode 100644 index 000000000..ccc069d31 --- /dev/null +++ b/Sources/AdaRender/Mesh/MorphTargetSet.swift @@ -0,0 +1,191 @@ +// +// MorphTargetSet.swift +// AdaEngine +// + +import Math + +/// The morph targets of one mesh part, packed for the GPU. +/// +/// Every target stores, for every vertex, a position delta and optionally a normal delta and a tangent delta, in one +/// half-float texture. The vertex shader reads the deltas of the few active targets with `texelFetch`, so any number of +/// targets cost memory but only the active ones cost time. Texel `i` is `(target * vertexCount + vertex) * attributes + +/// attribute`, laid out row by row in a texture ``textureWidth`` texels wide; the attributes are position, then normal and +/// tangent when any target has them. +public struct MorphTargetSet: Sendable { + /// Width of the delta texture in texels. + public static let textureWidth = 2048 + /// Tallest delta texture; with ``textureWidth`` this bounds `targets * vertices * attributes` to about 16.7 million texels. + public static let maximumTextureRows = 8192 + /// Most targets a draw applies at once; the heaviest weights win. + public static let maximumActiveTargets = 8 + + public let targetCount: Int + public let vertexCount: Int + /// Texels per vertex and target: 1 for positions only, up to 3 with normals and tangents. + public let attributesPerVertex: Int + public let hasNormals: Bool + public let hasTangents: Bool + public let targetNames: [String] + /// Per target, the longest position delta; an upper bound of how far the target can move any vertex. + public let maximumDisplacements: [Float] + public let deltaTexture: Texture2D + + /// Flags for the shader: bit 0 normals present, bit 1 tangents present. + public var attributeFlags: Float { Float((hasNormals ? 1 : 0) | (hasTangents ? 2 : 0)) } + + /// Packs per-target deltas. An empty array for a target and attribute means no displacement. Returns `nil` when there are + /// no targets or vertices, an array has the wrong length, a value is not finite, or the deltas would not fit the + /// texture. + public init?( + positionDeltas: [[Vector3]], + normalDeltas: [[Vector3]], + tangentDeltas: [[Vector3]], + vertexCount: Int, + targetNames: [String] = [] + ) { + let targetCount = positionDeltas.count + guard targetCount > 0, vertexCount > 0, normalDeltas.count == targetCount, tangentDeltas.count == targetCount else { + return nil + } + func valid(_ deltas: [[Vector3]]) -> Bool { + deltas.allSatisfy { $0.isEmpty || ($0.count == vertexCount && $0.allSatisfy { $0.x.isFinite && $0.y.isFinite && $0.z.isFinite }) } + } + guard valid(positionDeltas), valid(normalDeltas), valid(tangentDeltas) else { + return nil + } + let hasNormals = normalDeltas.contains { !$0.isEmpty } + let hasTangents = tangentDeltas.contains { !$0.isEmpty } + let attributes = 1 + (hasNormals ? 1 : 0) + (hasTangents ? 1 : 0) + let (texels, overflow) = targetCount.multipliedReportingOverflow(by: vertexCount) + let (total, overflowTotal) = texels.multipliedReportingOverflow(by: attributes) + let width = Self.textureWidth + guard !overflow, !overflowTotal, total <= width * Self.maximumTextureRows else { + return nil + } + let rows = (total + width - 1) / width + var halves = [UInt16](repeating: 0, count: width * rows * 4) + var displacements = [Float](repeating: 0, count: targetCount) + func write(_ deltas: [Vector3], target: Int, attribute: Int) { + guard !deltas.isEmpty else { return } + for vertex in 0.. UInt16 { + let bits = value.bitPattern + let sign = UInt16((bits >> 16) & 0x8000) + let exponent = Int((bits >> 23) & 0xFF) - 127 + 15 + let mantissa = bits & 0x7F_FFFF + if exponent >= 31 { + return sign | 0x7BFF + } + if exponent <= 0 { + guard exponent >= -10 else { return sign } + let significand = mantissa | 0x80_0000 + let shift = UInt32(14 - exponent) + var half = UInt16(significand >> shift) + if (significand >> (shift - 1)) & 1 == 1 { half &+= 1 } + return sign | half + } + var half = UInt16(exponent << 10) | UInt16(mantissa >> 13) + if mantissa & 0x1000 != 0 { half &+= 1 } + return sign | half + } + + /// The value of half-precision bits as a float. + @_spi(Internal) + public static func float(fromHalfBits half: UInt16) -> Float { + let sign: Float = half & 0x8000 != 0 ? -1 : 1 + let exponent = Int((half >> 10) & 0x1F) + let mantissa = Float(half & 0x3FF) + if exponent == 0 { return sign * mantissa * Float(sign: .plus, exponent: -24, significand: 1) } + if exponent == 31 { return sign * .infinity } + return sign * (1 + mantissa / 1024) * Float(sign: .plus, exponent: exponent - 15, significand: 1) + } + + /// The most the active weights can move any vertex, for growing culling bounds. Weights past the active limit are ignored + /// the way the shader ignores them. + public func maximumDisplacement(for weights: [Float]) -> Float { + var total: Float = 0 + for (index, weight) in Self.activeTargets(weights: weights) { + total += abs(weight) * maximumDisplacements[index] + } + return total + } + + /// The up to ``maximumActiveTargets`` targets with the largest weights, as (target index, weight). Targets whose weight is + /// zero or not finite are skipped. Ties keep the lower index first. + public static func activeTargets(weights: [Float], limit: Int = maximumActiveTargets) -> [(index: Int, weight: Float)] { + var candidates: [(index: Int, weight: Float)] = [] + for (index, weight) in weights.enumerated() where weight.isFinite && weight != 0 { + candidates.append((index, weight)) + } + candidates.sort { abs($0.weight) == abs($1.weight) ? $0.index < $1.index : abs($0.weight) > abs($1.weight) } + return Array(candidates.prefix(limit)) + } +} + +extension Mesh { + /// Whether any part of this mesh has morph targets. + public var hasMorphTargets: Bool { + models.contains { model in model.parts.contains { $0.morphTargets != nil } } + } + + /// The target names of the first morphed part, empty when there are none. + public var morphTargetNames: [String] { + for model in models { + for part in model.parts { + if let set = part.morphTargets { return set.targetNames } + } + } + return [] + } + + /// The number of morph targets of the first morphed part, zero when there are none. + public var morphTargetCount: Int { + for model in models { + for part in model.parts { + if let set = part.morphTargets { return set.targetCount } + } + } + return 0 + } +} diff --git a/Sources/AdaRender/RenderItems/Opaque3DRenderItem.swift b/Sources/AdaRender/RenderItems/Opaque3DRenderItem.swift index bbc165317..20120f297 100644 --- a/Sources/AdaRender/RenderItems/Opaque3DRenderItem.swift +++ b/Sources/AdaRender/RenderItems/Opaque3DRenderItem.swift @@ -23,6 +23,8 @@ public struct Opaque3DRenderItem: RenderItem { public let castShadows: Bool public let receiveShadows: Bool public let skinningBuffer: (any UniformBuffer)? + /// Weights of the active morph targets, for a part with ``Mesh/Part/morphTargets``. + public let morphBuffer: (any UniformBuffer)? public init( entity: Entity.ID, @@ -36,7 +38,8 @@ public struct Opaque3DRenderItem: RenderItem { castShadows: Bool = true, receiveShadows: Bool = true, batchRange: Range? = nil, - skinningBuffer: (any UniformBuffer)? = nil + skinningBuffer: (any UniformBuffer)? = nil, + morphBuffer: (any UniformBuffer)? = nil ) { self.entity = entity self.drawPass = drawPass @@ -50,5 +53,6 @@ public struct Opaque3DRenderItem: RenderItem { self.receiveShadows = receiveShadows self.batchRange = batchRange self.skinningBuffer = skinningBuffer + self.morphBuffer = morphBuffer } } diff --git a/Sources/AdaRender/Shaders/ShaderModule.swift b/Sources/AdaRender/Shaders/ShaderModule.swift index 4f1552d6b..816b520ec 100644 --- a/Sources/AdaRender/Shaders/ShaderModule.swift +++ b/Sources/AdaRender/Shaders/ShaderModule.swift @@ -95,7 +95,7 @@ extension ShaderModule { try ShaderCompiler(from: fileUrl).compileShaderModule() } - /// Compile a bundled shader with every name in `defines` set to `1` in the fragment stage. + /// Compile a bundled shader with every name in `defines` set to `1` in the vertex and fragment stages. /// /// The variant is compiled from the file's text rather than the file, so it bypasses the on-disk shader cache, whose /// entries are keyed by file and stage and would mix up variants of one file. Callers should keep the result. @@ -120,7 +120,9 @@ extension ShaderModule { ) let compiler = ShaderCompiler(shaderSource: source) for name in defines { - compiler.setMacro(name, value: "1", for: .fragment) + for stage in [ShaderStage.vertex, .fragment] { + compiler.setMacro(name, value: "1", for: stage) + } } return try compiler.compileShaderModule() #endif diff --git a/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift b/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift new file mode 100644 index 000000000..cfe888b50 --- /dev/null +++ b/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift @@ -0,0 +1,41 @@ +// +// MorphWeights3DComponent.swift +// AdaEngine +// + +import AdaECS + +/// The weights of an entity's morph targets (blend shapes), one per target of its mesh. +/// +/// A weight of 1 applies a target's full displacement and 0 none; values outside that range extrapolate. The renderer applies +/// at most eight targets at once, the ones with the largest weights, and moves vertices before skinning. A model instantiated +/// from glTF gets this component with the weights the file defines. +public struct MorphWeights3DComponent: Component { + public var weights: [Float] + /// Names of the targets in weight order, when the asset has them. + public let targetNames: [String] + + public init(weights: [Float], targetNames: [String] = []) { + self.weights = weights + self.targetNames = targetNames + } + + /// The index of the target with this name. + public func index(ofTarget name: String) -> Int? { + targetNames.firstIndex(of: name) + } + + /// The current weight of the target with this name. + public func weight(named name: String) -> Float? { + guard let index = index(ofTarget: name), weights.indices.contains(index) else { return nil } + return weights[index] + } + + /// Sets the weight of the target with this name. Returns `false` when there is no such target. + @discardableResult + public mutating func setWeight(_ weight: Float, named name: String) -> Bool { + guard let index = index(ofTarget: name), weights.indices.contains(index) else { return false } + weights[index] = weight + return true + } +} diff --git a/Sources/AdaScene/3D/Model3DPlugin.swift b/Sources/AdaScene/3D/Model3DPlugin.swift index d1ce5f668..b0b4daf8a 100644 --- a/Sources/AdaScene/3D/Model3DPlugin.swift +++ b/Sources/AdaScene/3D/Model3DPlugin.swift @@ -58,6 +58,7 @@ public struct Model3DPlugin: Plugin { .insertResource(AnimatedBounds3D()) .insertResource(Active3DInstanceBuffers()) .insertResource(Skinning3DUniforms()) + .insertResource(Morph3DUniforms()) .insertResource(PBR3DUniforms()) .insertResource(RenderPipelines(configurator: PBR3DPipeline())) .insertResource(RenderPipelines(configurator: Flat3DPipeline())) @@ -82,6 +83,9 @@ func ExtractModel3D( RelationshipComponent>>, _ skins: Extract>, + _ morphs: Extract>, + _ morphUniforms: ResMut, _ sources: ResMut, _ animatedBounds: ResMut, _ skinningUniforms: ResMut, @@ -101,6 +105,11 @@ func ExtractModel3D( lods.wrappedValue.forEach { entity, lod in lodByEntity[entity.id] = lod } var posedBounds: [Entity.ID: AABB] = [:] skinningUniforms.wrappedValue.beginFrame(device: renderDevice.renderDevice) + morphUniforms.wrappedValue.beginFrame() + var morphByEntity: [Entity.ID: [Float]] = [:] + morphs.wrappedValue.forEach { entity, morph in morphByEntity[entity.id] = morph.weights } + // Morph deltas are applied before skinning, so a joint matrix that scales also scales the displacement. + var skinScale: [Entity.ID: Float] = [:] skins.wrappedValue.forEach { entity, skin in _ = skinningUniforms.wrappedValue.write(skin.matrices, for: entity.id, device: renderDevice.renderDevice, revision: .init(id: skin.paletteID, generation: skin.poseGeneration)) if let mesh = entity.components[Mesh3DComponent.self]?.mesh { @@ -111,6 +120,11 @@ func ExtractModel3D( : animatedBounds.wrappedValue.bounds(mesh: mesh, matrices: skin.matrices) posedBounds[entity.id] = bounds } + var largest: Float = 1 + for matrix in skin.matrices { + largest = max(largest, matrix.x.xyz.length, matrix.y.xyz.length, matrix.z.xyz.length) + } + skinScale[entity.id] = largest } materialUniforms.wrappedValue.beginFrame() @@ -134,6 +148,22 @@ func ExtractModel3D( if palette == nil, let lod { for level in lod.alternatives { localBounds = localBounds.map { AABB(min: min($0.min, level.bounds.min), max: max($0.max, level.bounds.max)) } ?? level.bounds } } + // Weights move vertices past the rest-pose bounds, so culling and shadow bounds grow by the most the weights can displace. + var morphBuffers: [UInt64: any UniformBuffer] = [:] + if mesh.hasMorphTargets, let weights = morphByEntity[entity.id] { + let written = morphUniforms.wrappedValue.write(weights: weights, mesh: mesh, for: entity.id, device: renderDevice.renderDevice) + var displacement: Float = 0 + for (key, buffer) in written { + morphBuffers[Mesh3DRenderSource.morphKey(model: key.model, part: key.part)] = buffer + if let set = mesh.models[key.model].parts[key.part].morphTargets { + displacement = max(displacement, set.maximumDisplacement(for: weights)) + } + } + if displacement > 0, let bounds = localBounds { + let growth = Vector3.one * (displacement * (skinScale[entity.id] ?? 1)) + localBounds = AABB(min: bounds.min - growth, max: bounds.max + growth) + } + } sources.meshes.append(Mesh3DRenderSource( entity: entity.id, drawPass: drawPass.wrappedValue, @@ -141,7 +171,8 @@ func ExtractModel3D( materials: mesh3d.materials, transform: transform.matrix, bounds: localBounds.map { MeshVisibility3DMath.transformed($0, by: transform.matrix) }, - alternatives: palette == nil || lod?.skeletonCompatible == true && localBounds != nil ? lod?.alternatives ?? [] : [], + // Level-of-detail meshes have their own vertices, which the morph deltas do not address. + alternatives: morphBuffers.isEmpty && (palette == nil || lod?.skeletonCompatible == true && localBounds != nil) ? lod?.alternatives ?? [] : [], thresholds: lod?.screenThresholds ?? [], hysteresis: lod?.hysteresis ?? 0.15, maximumDistance: lod?.maximumDistance, @@ -150,7 +181,8 @@ func ExtractModel3D( castShadows: mesh3d.castShadows, receiveShadows: mesh3d.receiveShadows, skinningBuffer: palette, - previousTransform: previousTransforms[entity.id] + previousTransform: previousTransforms[entity.id], + morphBuffers: morphBuffers )) } @@ -180,12 +212,13 @@ public final class Model3DDrawPass: DrawPass, @unchecked Sendable { } let isMotion = world.getResource(ActiveMotion3DPass.self)?.isEnabled == true + let morphed = item.morphBuffer != nil && part.morphTargets != nil let configuration = PBR3DConfiguration( vertex: part.vertexDescriptor, blended: (item.material as? PBRMaterial)?.alphaMode == .blend, temporalDepth: view.components[RenderViewTarget.self]?.temporalUpscalingActive == true, // The motion shader has no shading layers, so its pipelines are not split by them. - features: isMotion ? [] : (item.material as? PBRMaterial)?.shaderFeatures ?? [] + features: (isMotion ? [] : (item.material as? PBRMaterial)?.shaderFeatures ?? []).union(morphed ? .morph : []) ) let pipeline: any RenderPipeline if isMotion { @@ -219,6 +252,10 @@ public final class Model3DDrawPass: DrawPass, @unchecked Sendable { let heightTexture = pbrMaterial?.heightTexture ?? Texture2D.whiteTexture // The main shader variant declares exactly the layer textures the material has (see PBRMaterial.shaderFeatures). var layerBindings: [RenderResourceSet.Binding] = [] + if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { + layerBindings.append(.init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0))) + layerBindings.append(.init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture))) + } if !isMotion, let pbrMaterial { let features = pbrMaterial.shaderFeatures for kind in PBRLayerTexture.allCases where features.contains(kind.feature) { diff --git a/Sources/AdaScene/3D/ModelAsset3D.swift b/Sources/AdaScene/3D/ModelAsset3D.swift index 8e62d5d85..6df4a6a78 100644 --- a/Sources/AdaScene/3D/ModelAsset3D.swift +++ b/Sources/AdaScene/3D/ModelAsset3D.swift @@ -26,6 +26,8 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { public let restPose: GLTFImportResult.NodeTRS? /// Keyed transform and mesh animation of imported voxel scenes. public let voxelAnimation: VoxelNodeAnimation? + /// Morph target weights of this node's mesh instance; they replace the mesh's default weights. + public let morphWeights: [Float]? public init( name: String?, @@ -34,8 +36,10 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { children: [Int], skinIndex: Int? = nil, restPose: GLTFImportResult.NodeTRS? = nil, - voxelAnimation: VoxelNodeAnimation? = nil + voxelAnimation: VoxelNodeAnimation? = nil, + morphWeights: [Float]? = nil ) { + self.morphWeights = morphWeights self.voxelAnimation = voxelAnimation self.name = name self.transform = transform @@ -54,6 +58,8 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { public let skins: [GLTFImportResult.Skin] public let animationRig: SkeletalRig? public let animationClips: [SkeletalAnimationClip] + /// Default morph target weights of each mesh, empty for a mesh without morph targets. + public private(set) var meshMorphWeights: [[Float]] = [] /// Content of the source file the importer could not honor, such as glTF extensions it does not implement. /// Empty for formats that report nothing. The model still loads and renders without that content. public private(set) var importWarnings: [String] = [] @@ -206,7 +212,7 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { descriptor.indicies = converted.indices descriptor.primitiveTopology = converted.topology - let part = Mesh.Part( + var part = Mesh.Part( id: index, materialIndex: primitive.materialIndex ?? 0, primitiveTopology: descriptor.primitiveTopology, @@ -217,13 +223,28 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { indexCount: descriptor.indicies.count, vertexBuffer: descriptor.getVertexBuffer(renderDevice: device) ) + if !primitive.morphTargets.isEmpty { + guard let morph = MorphTargetSet( + positionDeltas: primitive.morphTargets.map(\.positionDeltas), + normalDeltas: primitive.morphTargets.map(\.normalDeltas), + tangentDeltas: primitive.morphTargets.map(\.tangentDeltas), + vertexCount: descriptor.positions.count, + targetNames: gltfMesh.targetNames + ) else { + throw AssetError.message("The \(primitive.morphTargets.count) morph targets of mesh \"\(gltfMesh.name ?? "")\" do not fit the delta texture (at most \(MorphTargetSet.textureWidth * MorphTargetSet.maximumTextureRows) texels of vertices x targets x attributes).") + } + part.morphTargets = morph + } parts.append(part) } meshes.append(Mesh(models: [Mesh.Model(name: gltfMesh.name ?? "", parts: parts)])) + meshMorphWeights.append(gltfMesh.defaultWeights) } - nodes = result.nodes.map { Node(name: $0.name, transform: $0.transform, meshIndex: $0.meshIndex, children: $0.children, skinIndex: $0.skinIndex, restPose: $0.restPose) } + nodes = result.nodes.map { + Node(name: $0.name, transform: $0.transform, meshIndex: $0.meshIndex, children: $0.children, skinIndex: $0.skinIndex, restPose: $0.restPose, morphWeights: $0.weights) + } self.meshes = meshes self.materials = materials scenes = result.scenes @@ -297,6 +318,11 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { if let meshIndex = node.meshIndex { let mesh = meshes[meshIndex] entity.components[Mesh3DComponent.self] = Mesh3DComponent(mesh: mesh, materials: materials) + if mesh.hasMorphTargets { + let defaults = meshMorphWeights.indices.contains(meshIndex) ? meshMorphWeights[meshIndex] : [] + let weights = node.morphWeights ?? (defaults.count == mesh.morphTargetCount ? defaults : [Float](repeating: 0, count: mesh.morphTargetCount)) + entity.components[MorphWeights3DComponent.self] = MorphWeights3DComponent(weights: weights, targetNames: mesh.morphTargetNames) + } if let skinIndex = node.skinIndex, skins.indices.contains(skinIndex), let animation = modelRoot.components[ModelAnimation3DComponent.self] { var binding = SkinnedMesh3DComponent(modelRoot: modelRoot.id, meshNodeIndex: nodeIndex, skin: skins[skinIndex]) diff --git a/Tests/AdaEngineTests/Render3DMorphTargetTests.swift b/Tests/AdaEngineTests/Render3DMorphTargetTests.swift new file mode 100644 index 000000000..94aa649ed --- /dev/null +++ b/Tests/AdaEngineTests/Render3DMorphTargetTests.swift @@ -0,0 +1,363 @@ +#if os(macOS) +import AdaAssets +import AdaECS +@_spi(Internal) import AdaRender +import AdaScene +import AdaTransform +import AdaUtils +import Foundation +import Math +import Testing + +@testable import AdaCorePipelines + +/// Morph target packing and selection; no GPU needed except where a texture is created. +@Suite("3D morph target selection") +struct Render3DMorphTargetSelectionTests { + @Test func halfPrecisionConversionRoundTrips() { + for value: Float in [0, 1, -1, 0.5, 0.6, -0.333, 100.25, 0.0001, 65504, -65504] { + let half = MorphTargetSet.halfBits(value) + let back = MorphTargetSet.float(fromHalfBits: half) + #expect(abs(back - value) <= max(abs(value) * 0.001, 0.00001), "\(value) came back as \(back).") + } + #expect(MorphTargetSet.halfBits(1) == 0x3C00 && MorphTargetSet.halfBits(-2) == 0xC000 && MorphTargetSet.halfBits(0) == 0) + #expect(MorphTargetSet.halfBits(1e9) == 0x7BFF && MorphTargetSet.halfBits(-1e9) == 0xFBFF, "Out-of-range values saturate to the largest finite half.") + #expect(MorphTargetSet.float(fromHalfBits: MorphTargetSet.halfBits(1e-9)) == 0, "Values below the half range flush to zero.") + } + + @Test func theHeaviestWeightsWin() { + let weights: [Float] = [0.1, -0.9, 0, 0.5, 0.5, 2, .nan, 0.05, 0.3, 0.2, 0.4] + let active = MorphTargetSet.activeTargets(weights: weights) + #expect(active.count == 8) + #expect(active.map(\.index) == [5, 1, 3, 4, 10, 8, 9, 0], "Largest magnitude first, ties by index; zero and NaN weights skipped.") + #expect(active.first?.weight == 2 && active[1].weight == -0.9) + #expect(MorphTargetSet.activeTargets(weights: [0, 0, .infinity]).isEmpty) + #expect(MorphTargetSet.activeTargets(weights: [0.5, 0.6, 0.7], limit: 2).map(\.index) == [2, 1]) + } + + @Test func uniformPackingHoldsCurrentAndPreviousTargets() { + var vectors = [Vector4](repeating: .one, count: Morph3DUniforms.vectorCountForTests) + let current: [(index: Int, weight: Float)] = [(3, 0.5), (1, 0.25)] + let previous: [(index: Int, weight: Float)] = [(3, 0.4)] + // Only the counts, flags and entries matter, so a set is not needed to check the layout. + Morph3DUniforms.packForTests(current: current, previous: previous, vertexCount: 120, attributes: 2, flags: 1, into: &vectors) + #expect(vectors[0] == Vector4(2, 1, 120, 2) && vectors[1] == Vector4(1, 0, 0, 0)) + #expect(vectors[2] == Vector4(3, 0.5, 0, 0) && vectors[3] == Vector4(1, 0.25, 0, 0) && vectors[4] == .zero) + #expect(vectors[10] == Vector4(3, 0.4, 0, 0) && vectors[11] == .zero) + } + + @Test func morphKeysIdentifyModelAndPart() { + #expect(Mesh3DRenderSource.morphKey(model: 0, part: 0) != Mesh3DRenderSource.morphKey(model: 1, part: 0)) + #expect(Mesh3DRenderSource.morphKey(model: 0, part: 1) != Mesh3DRenderSource.morphKey(model: 1, part: 0)) + #expect(Set((0..<4).flatMap { model in (0..<4).map { Mesh3DRenderSource.morphKey(model: model, part: $0) } }).count == 16) + } + + @Test func componentFindsTargetsByName() { + var component = MorphWeights3DComponent(weights: [0, 0.5], targetNames: ["smile", "blink"]) + #expect(component.weight(named: "blink") == 0.5 && component.weight(named: "frown") == nil) + #expect(component.setWeight(1, named: "smile") && component.weights == [1, 0.5]) + #expect(!component.setWeight(1, named: "frown") && component.weights == [1, 0.5]) + } +} + +extension Render3DGPUTests { + /// Run alone: `ADAENGINE_GOLDEN_METAL=1 swift test --filter Render3DGPUTests/MorphTargets`. + @Suite("3D morph targets on GPU") + @MainActor + struct MorphTargets { + /// A sphere with one target per entry of `shifts`, each moving every vertex by that vector at weight 1. + private static func morphedSphere(device: RenderDevice, shifts: [Vector3], withNormalDeltas: Bool = false) throws -> Mesh { + let base = Mesh.generateSphere(radius: 0.5, renderDevice: device) + var part = try #require(base.models.first?.parts.first) + let positions = part.meshDescriptor.positions.elements + let normals = part.meshDescriptor.normals?.elements ?? [] + part.morphTargets = try #require(MorphTargetSet( + positionDeltas: shifts.map { shift in positions.map { _ in shift } }, + // A normal delta that leans every normal toward +X. + normalDeltas: shifts.map { _ in withNormalDeltas ? normals.map { _ in Vector3(0.8, 0, 0) } : [] }, + tangentDeltas: shifts.map { _ in [] }, + vertexCount: positions.count, + targetNames: shifts.indices.map { "target \($0)" } + )) + return Mesh(models: [Mesh.Model(name: "Morphed", parts: [part])]) + } + + private struct Fixture { + let scene: Render3DGPUTests.DebugViews.Scene + let probe: Entity + + func place(at position: Vector3) { + probe.components[Transform.self] = Transform(position: position) + } + + func setWeights(_ weights: [Float]) { + var component = probe.components[MorphWeights3DComponent.self] + component?.weights = weights + probe.components[MorphWeights3DComponent.self] = component + } + } + + private static let home: Vector3 = [-0.3, 0.5, -2.2] + + private static func makeFixture(shifts: [Vector3], withNormalDeltas: Bool = false, localLight: Bool = false) async throws -> Fixture { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let material = PBRMaterial() + material.baseColorFactor = [0.7, 0.35, 0.3, 1] + material.metallicFactor = 0 + material.roughnessFactor = 0.45 + let mesh = try morphedSphere(device: scene.device, shifts: shifts, withNormalDeltas: withNormalDeltas) + let probe = scene.app.main.spawn("Probe") { + Mesh3DComponent(mesh: mesh, materials: [material]) + MorphWeights3DComponent(weights: shifts.map { _ in 0 }, targetNames: mesh.morphTargetNames) + Transform(position: home) + } + if localLight { + scene.app.main.spawn("Shadow Point") { + PointLightComponent(radiance: [1, 0.9, 0.8], intensity: 10, castShadows: true, range: 6) + Transform(position: [1.4, 2.2, -1.4]) + } + } + return Fixture(scene: scene, probe: probe) + } + + private static func matches(_ left: Image, _ right: Image) -> Bool { + GoldenFrame.compare(left, right, tolerance: GoldenFrame.activeTolerance).passes(GoldenFrame.activeTolerance) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func zeroWeightsDrawTheRestShape() async throws { + let fixture = try await Self.makeFixture(shifts: [[0.6, 0, 0]], localLight: true) + let morphedMesh = try await fixture.scene.render(.none) + // The same sphere without any morph data. + var component = try #require(fixture.probe.components[Mesh3DComponent.self]) + let plain = Mesh.generateSphere(radius: 0.5, renderDevice: fixture.scene.device) + component.mesh = plain + fixture.probe.components[Mesh3DComponent.self] = component + fixture.probe.components[MorphWeights3DComponent.self] = nil + #expect(Self.matches(morphedMesh, try await fixture.scene.render(.none)), "Weight zero must look exactly like the unmorphed mesh.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled), arguments: [0.25 as Float, 0.5, 1]) + func aWeightedShiftMatchesMovingTheEntity(weight: Float) async throws { + let fixture = try await Self.makeFixture(shifts: [[0.6, 0, 0]], localLight: true) + fixture.setWeights([weight]) + let morphed = try await fixture.scene.render(.none) + fixture.setWeights([0]) + fixture.place(at: Self.home + Vector3(0.6 * weight, 0, 0)) + let moved = try await fixture.scene.render(.none) + #expect(Self.matches(morphed, moved), "A morph shift of \(0.6 * weight) must equal moving the entity by it, shadows from the sun and a local light included.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func weightsChangingEveryFrameUpdateTheShadows() async throws { + let fixture = try await Self.makeFixture(shifts: [[0.6, 0, 0]], localLight: true) + // Several settled frames, then a change: the local shadow faces must not be reused from before the change. + fixture.setWeights([0.1]) + _ = try await fixture.scene.render(.none, frames: 4) + fixture.setWeights([0.9]) + let morphed = try await fixture.scene.render(.none, frames: 1) + fixture.setWeights([0]) + fixture.place(at: Self.home + Vector3(0.54, 0, 0)) + #expect(Self.matches(morphed, try await fixture.scene.render(.none, frames: 1)), "The shadows must follow the new weight on the first frame.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func onlyTheHeaviestEightTargetsApply() async throws { + let shifts = (0..<10).map { Vector3(0.04 * Float($0 + 1), 0, 0) } + let fixture = try await Self.makeFixture(shifts: shifts) + let weights = (0..<10).map { 1 - 0.05 * Float($0) } + fixture.setWeights(weights) + let morphed = try await fixture.scene.render(.none) + var expected: Float = 0 + for index in 0..<8 { expected += weights[index] * 0.04 * Float(index + 1) } + fixture.setWeights([Float](repeating: 0, count: 10)) + fixture.place(at: Self.home + Vector3(expected, 0, 0)) + #expect(Self.matches(morphed, try await fixture.scene.render(.none)), "Targets nine and ten have the smallest weights and must be ignored.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func morphingBringsAnOffscreenMeshIntoView() async throws { + // Resting far to the left, outside the frustum; the shift brings it to the middle. Culling must use the morphed bounds. + let fixture = try await Self.makeFixture(shifts: [[8, 0, 0]]) + fixture.place(at: Vector3(-8.3, 0.5, -2.2)) + fixture.setWeights([1]) + let morphed = try await fixture.scene.render(.none) + fixture.setWeights([0]) + fixture.place(at: Self.home) + #expect(Self.matches(morphed, try await fixture.scene.render(.none)), "The morphed mesh must not be culled by its rest bounds.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func normalDeltasChangeTheShadingButNotTheShape() async throws { + let fixture = try await Self.makeFixture(shifts: [[0, 0, 0]], withNormalDeltas: true) + let rest = try await fixture.scene.render(.none) + fixture.setWeights([1]) + let leaned = try await fixture.scene.render(.none) + #expect(!Self.matches(rest, leaned), "Leaning the normals must change the lighting.") + // The silhouette along the middle row is where the sphere meets the background, and it must not move. + func edges(_ image: Image) -> [Int] { + let background = image.getPixel(x: 2, y: 240) + var result: [Int] = [] + var previousInside = false + for x in 0..<640 { + let pixel = image.getPixel(x: x, y: 240) + let inside = abs(pixel.red - background.red) + abs(pixel.green - background.green) + abs(pixel.blue - background.blue) > 0.35 + if inside != previousInside { result.append(x) } + previousInside = inside + } + return result + } + let restEdges = edges(rest) + #expect(!restEdges.isEmpty && restEdges == edges(leaned), "Normal deltas move no vertex.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func morphedMeshesWorkWithGPUOcclusion() async throws { + let fixture = try await Self.makeFixture(shifts: [[0.6, 0, 0]]) + try fixture.scene.configure { $0.gpuOcclusionEnabled = true } + fixture.setWeights([0.7]) + let morphed = try await fixture.scene.render(.none) + fixture.setWeights([0]) + fixture.place(at: Self.home + Vector3(0.42, 0, 0)) + #expect(Self.matches(morphed, try await fixture.scene.render(.none)), "The visibility prepass must draw the morphed shape.") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func morphedMeshesDrawThroughTheTemporalMotionPass() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene(temporalRenderScale: 1) + let device = scene.device + let material = PBRMaterial() + material.baseColorFactor = [0.7, 0.35, 0.3, 1] + material.metallicFactor = 0 + let mesh = try Self.morphedSphere(device: device, shifts: [[0.6, 0, 0]]) + let probe = scene.app.main.spawn("Probe") { + Mesh3DComponent(mesh: mesh, materials: [material]) + MorphWeights3DComponent(weights: [0.5], targetNames: mesh.morphTargetNames) + Transform(position: Self.home) + } + let fixture = Fixture(scene: scene, probe: probe) + // Temporal accumulation is not exactly repeatable, so compare by which reference the frame is closer to. + func difference(_ left: Image, _ right: Image) -> Int { + GoldenFrame.compare(left, right, tolerance: GoldenFrame.activeTolerance).differingPixels + } + let morphed = try await scene.render(.none, frames: 16) + fixture.setWeights([0]) + fixture.place(at: Self.home + Vector3(0.3, 0, 0)) + let moved = try await scene.render(.none, frames: 16) + fixture.place(at: Self.home) + let rest = try await scene.render(.none, frames: 16) + let toMoved = difference(morphed, moved), toRest = difference(morphed, rest) + #expect(toMoved * 4 < toRest, "Through the motion pass the morphed mesh must still land where its weights put it (\(toMoved) pixels from the moved mesh, \(toRest) from the rest shape).") + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func previousWeightsAreKeptForMotionVectors() async throws { + let fixture = try await Self.makeFixture(shifts: [[0.6, 0, 0], [0, 0.6, 0]]) + let renderWorld = try #require(fixture.scene.app.getSubworldBuilder(by: .renderWorld)?.main) + func header() throws -> (current: Vector4, firstCurrent: Vector4, firstPrevious: Vector4) { + let uniforms = try #require(renderWorld.getResource(Morph3DUniforms.self)) + let buffer = try #require(uniforms.debugBuffers().first) + let vectors = unsafe (0..<18).map { buffer.contents().load(fromByteOffset: $0 * 16, as: Vector4.self) } + return (vectors[0], vectors[2], vectors[10]) + } + fixture.setWeights([0.2, 0]) + _ = try await fixture.scene.render(.none, frames: 3) + fixture.setWeights([0.8, 0]) + _ = try await fixture.scene.render(.none, frames: 1) + let moved = try header() + #expect(moved.current.x == 1 && moved.current.y == 1) + #expect(moved.firstCurrent == Vector4(0, 0.8, 0, 0) && moved.firstPrevious == Vector4(0, 0.2, 0, 0), "The previous frame's weight must sit next to the current one.") + _ = try await fixture.scene.render(.none, frames: 1) + let settled = try header() + #expect(settled.firstCurrent == Vector4(0, 0.8, 0, 0) && settled.firstPrevious == Vector4(0, 0.8, 0, 0), "With nothing changing, previous equals current.") + } + + /// A one-quad glTF skinned to a single joint that is turned a quarter turn about Z, with one morph target that moves + /// every vertex by +0.6 on X (mesh weight 1 by default). Written to a temporary file and loaded like any asset. + private static func skinnedMorphModel() throws -> ModelAsset3D { + var bytes = Data() + var views: [[String: Any]] = [] + func add(_ data: Data) { + views.append(["buffer": 0, "byteOffset": bytes.count, "byteLength": data.count]) + bytes.append(data) + while bytes.count % 4 != 0 { bytes.append(0) } + } + func floats(_ values: [Float]) -> Data { + var data = Data() + for value in values { withUnsafeBytes(of: value.bitPattern.littleEndian) { data.append(contentsOf: $0) } } + return data + } + add(floats([-0.5, 0, 0, 0.5, 0, 0, -0.5, 1, 0, 0.5, 1, 0])) + add(floats([0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1])) + var indices = Data() + for index in [0, 1, 2, 2, 1, 3] as [UInt16] { withUnsafeBytes(of: index.littleEndian) { indices.append(contentsOf: $0) } } + add(indices) + add(Data([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + add(floats([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0])) + add(floats([0.6, 0, 0, 0.6, 0, 0, 0.6, 0, 0, 0.6, 0, 0])) + add(floats([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])) + func accessor(_ view: Int, _ type: String, _ count: Int, _ component: Int = 5126) -> [String: Any] { + ["bufferView": view, "componentType": component, "count": count, "type": type] + } + var positionAccessor = accessor(0, "VEC3", 4) + positionAccessor["min"] = [-0.5, 0, 0] + positionAccessor["max"] = [0.5, 1, 0] + let document: [String: Any] = [ + "asset": ["version": "2.0"], + "buffers": [["byteLength": bytes.count, "uri": "data:application/octet-stream;base64," + bytes.base64EncodedString()]], + "bufferViews": views, + "accessors": [ + positionAccessor, accessor(1, "VEC3", 4), accessor(2, "SCALAR", 6, 5123), accessor(3, "VEC4", 4, 5121), + accessor(4, "VEC4", 4), accessor(5, "VEC3", 4), accessor(6, "MAT4", 1), + ], + "materials": [["doubleSided": true, "pbrMetallicRoughness": ["baseColorFactor": [0.8, 0.4, 0.3, 1], "metallicFactor": 0]]], + "meshes": [[ + "weights": [1], + "extras": ["targetNames": ["shift"]], + "primitives": [[ + "attributes": ["POSITION": 0, "NORMAL": 1, "JOINTS_0": 3, "WEIGHTS_0": 4], + "indices": 2, "material": 0, "targets": [["POSITION": 5]], + ]], + ]], + "skins": [["joints": [1], "inverseBindMatrices": 6, "skeleton": 1]], + "nodes": [["mesh": 0, "skin": 0], ["rotation": [0, 0, 0.7071068, 0.7071068]]], + "scenes": [["nodes": [0, 1]]], + "scene": 0, + ] + let url = FileManager.default.temporaryDirectory.appendingPathComponent("morph-skin-\(UUID().uuidString).gltf") + try JSONSerialization.data(withJSONObject: document).write(to: url) + return try #require(AssetsManager.loadSync(ModelAsset3D.self, at: url.path).asset) + } + + private static func morphedEntity(under root: Entity) -> Entity? { + if root.components[MorphWeights3DComponent.self] != nil { return root } + for child in root.children { + if let found = morphedEntity(under: child) { return found } + } + return nil + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func importedMorphTargetsApplyBeforeSkinning() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let model = try Self.skinnedMorphModel() + #expect(model.meshes[0].hasMorphTargets && model.meshes[0].morphTargetNames == ["shift"] && model.meshMorphWeights == [[1]]) + let root = model.instantiate(in: scene.app.main) + root.components[Transform.self] = Transform(position: [0, 0, -1.6]) + let mesh = try #require(Self.morphedEntity(under: root)) + #expect(mesh.components[MorphWeights3DComponent.self]?.weights == [1], "The file's default weight must reach the entity.") + let morphed = try await scene.render(.none, frames: 4) + // Morph first, then skin: the quarter turn carries the +0.6 X shift to +0.6 on Y. Skinning first would leave it on X. + var component = try #require(mesh.components[MorphWeights3DComponent.self]) + component.weights = [0] + mesh.components[MorphWeights3DComponent.self] = component + root.components[Transform.self] = Transform(position: [0, 0.6, -1.6]) + let shiftedUp = try await scene.render(.none, frames: 4) + root.components[Transform.self] = Transform(position: [0.6, 0, -1.6]) + let shiftedAcross = try await scene.render(.none, frames: 4) + #expect(Self.matches(morphed, shiftedUp), "A morph shift must be turned by the skin (morph first, then skin).") + #expect(!Self.matches(morphed, shiftedAcross)) + } + } +} +#endif From a7414d7fcbf8b7afbd645fba03bb192f2c49e474 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 13:49:26 +0300 Subject: [PATCH 09/11] 3D: play glTF morph weight animation, with crossfades and the animation graph, and script access Weights channels import as MorphWeightsTrack (linear, step, cubic spline), join the clip duration, and are sampled by SkeletalAnimationPlayer into morphWeights by rig node, with rest weights from the file, crossfades, and blend, mask and additive rules matching the poses in the animation graph. SkeletalAnimation3DSystem writes them to the node's MorphWeights3DComponent. The component becomes a reflected, Codable, registered @Component; because reflection exposes scalars only it offers a selected target (index or name) and its weight for scripts and the inspector. Adds 17 CPU animation tests, a GPU test that plays an animated glTF end to end, and component reflection and Codable tests. Records the design and what remains (an Editor panel) in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 36 ++- .../Skeletal/CompiledAnimationGraph.swift | 47 ++++ .../Skeletal/MorphWeightsTrack.swift | 78 +++++++ .../Skeletal/SkeletalAnimation.swift | 12 +- .../Skeletal/SkeletalAnimationPlayer.swift | 29 ++- .../Components/MorphWeights3DComponent.swift | 39 +++- Sources/AdaScene/3D/Model3DPlugin.swift | 1 + .../AdaScene/3D/ModelAsset3D+Animation.swift | 43 +++- Sources/AdaScene/3D/ModelAsset3D.swift | 4 +- Sources/AdaScene/3D/SkeletalAnimation3D.swift | 11 + .../MorphWeightsAnimationTests.swift | 207 ++++++++++++++++++ .../Render3DMorphTargetTests.swift | 77 ++++++- 12 files changed, 567 insertions(+), 17 deletions(-) create mode 100644 Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift create mode 100644 Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 07a2cf401..82d168498 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -303,7 +303,8 @@ Each item needs its own short requirements note before implementation. - [x] Stage 2: unsupported-extension degradation policy and documented supported list (`NativeGLTFLoader.supportedExtensions`, `GLTFImportResult.warnings`). - [x] Stage 3: morph-target asset, glTF import and GPU evaluation (see "Stage 3, first slice"). - [x] Stage 3: morph with skinning, shadows, motion vectors and bounds. -- [ ] Stage 3: animated weights (clips and the animation graph), AdaScript and Editor exposure. +- [x] Stage 3: animated weights (clips, crossfades and the animation graph) and AdaScript access (see "Stage 3, second slice"). +- [ ] Stage 3: Editor panel for morph weights. - [ ] Stage 4: OIT mode with sorted fallback. - [ ] Stage 4: transmission/volume pass. - [x] Stage 5: distance/height fog (`Fog3D` in `Environment3D`, applied in the composite pass; see "Stage 5, first slice"). @@ -919,7 +920,38 @@ applies its morph before the skin. Each shader read was checked by breaking it a Limits: deltas are dense and half precision (no sparse or compacted storage, so a face with many targets costs texture memory in proportion to targets x vertices); the selection is made per frame on the CPU; only POSITION, NORMAL and TANGENT morph; layered materials and morph share the shader-variant mechanism, so the first draw of a morphed mesh compiles its variants (about a second each), and there is no web -build. Not done: playing `weights` channels (clips and the animation graph), the Editor and AdaScript. +build. Not done at this slice: playing `weights` channels, AdaScript and the Editor (see the second slice). + +### Stage 3, second slice: animated weights and script access (2026-10-19) + +**Clips.** glTF `weights` channels import as `MorphWeightsTrack` (a validated track of `targetCount` weights per key, linear, +step or cubic spline with per-target tangents scaled by the key span, sampled without allocating) in `SkeletalAnimationClip.morphTracks`; +they count toward the clip duration and are valid on matrix-placed nodes. A rig node carries `morphRestWeights` (the node's weights, else the +mesh's defaults), and a player rejects a track that addresses a node with a different target count. + +**Playback.** `SkeletalAnimationPlayer.morphWeights` holds the weights by rig node (empty where the node has no targets). Without a graph a +clip's tracks overwrite the rest weights at its local time, looping and clamping like poses; a crossfade (`transitionDuration`) +interpolates from the weights at the time of the switch. In a graph, blend nodes mix weights by input weight and mask with the rest weights +filling a gap (a mask is read at the mesh node's rig index), and additive nodes add each further input's offset from the rest weights, +the same rules the poses follow. + +**Scene.** `SkeletalAnimation3DSystem` writes `player.morphWeights` into the node's `MorphWeights3DComponent` whenever the pose generation +changes, only for nodes the animation drives (matching, non-empty weights), so hand-set weights on other nodes are not touched. The +renderer already treats weight changes as dynamic (shadow faces, bounds, previous-frame weights), so animated morphs need nothing more. + +**Scripts.** `MorphWeights3DComponent` is a reflected `@Component`, registered with the model plugin and Codable (a missing key +decodes to its default). Reflection exposes scalars only, so arrays show as read-only fields; scripts and the inspector steer one target at a +time through `selectedTarget`, `selectedTargetName` (assigning a name selects that target) and `selectedTargetWeight` (assigning a finite +value sets the weight), and code uses `weights` and `setWeight(_:named:)`. + +**Tests.** 17 CPU tests (`MorphWeightsAnimationTests`): sampling and rejection, the player (rest weights, clip, loop and clamp, independent +players, crossfade, track validation, duration), and the graph (blend with rest fill, mask on the mesh node, additive, clips without +weight tracks). A GPU test loads a glTF with an animated weights channel through `ModelAsset3D`, seeks to half a second, checks that the +entity holds `[0.5]`, that the frame equals the same weight set by hand, and that the end of the clip applies the full shift; reflection +and Codable tests cover the component. + +Not done: an Editor panel (the Editor package keeps its own component catalog and builds separately) and animating weights from keyframe +`AdaAnimation` clips (only glTF clips and the skeletal graph drive weights); an AdaScript sample. ## Validation requirements diff --git a/Sources/AdaAnimation/Skeletal/CompiledAnimationGraph.swift b/Sources/AdaAnimation/Skeletal/CompiledAnimationGraph.swift index 4bbfd55fe..654b4e96f 100644 --- a/Sources/AdaAnimation/Skeletal/CompiledAnimationGraph.swift +++ b/Sources/AdaAnimation/Skeletal/CompiledAnimationGraph.swift @@ -10,6 +10,9 @@ struct CompiledAnimationGraph: Sendable { let clipIndices: [Int?] let rest: [SkeletalJointPose] var poses: [[SkeletalJointPose]] + /// Morph weights per graph node and rig node (empty for rig nodes without morph targets). + var morph: [[[Float]]] + let restMorph: [[Float]] var contributions: [Float] let duration: Double let loops: Bool @@ -76,6 +79,9 @@ struct CompiledAnimationGraph: Sendable { self.clipIndices = clipIndices self.rest = rest self.poses = graph.nodes.map { _ in rest } + let restMorph = rig.nodes.map { $0.morphRestWeights ?? [] } + self.restMorph = restMorph + self.morph = graph.nodes.map { _ in restMorph } self.contributions = [Float](repeating: 0, count: graph.nodes.count) var duration: Double = 0 var loops = false @@ -132,9 +138,18 @@ struct CompiledAnimationGraph: Sendable { case .scale: poses[index][track.nodeIndex].scale = value.xyz } } + for joint in restMorph.indices where !restMorph[joint].isEmpty { + for target in restMorph[joint].indices { morph[index][joint][target] = restMorph[joint][target] } + } + for track in clip.morphTracks { + track.sample(at: local, into: &morph[index][track.nodeIndex]) + } } else { for joint in rest.indices { poses[index][joint] = node.kind == .blend ? blend(index, joint: joint) : additive(index, joint: joint) + if !restMorph[joint].isEmpty { + morph[index][joint] = node.kind == .blend ? blendMorph(index, joint: joint) : additiveMorph(index, joint: joint) + } } } } @@ -173,6 +188,38 @@ struct CompiledAnimationGraph: Sendable { return SkeletalJointPose(translation: translation / denominator, rotation: quaternion.squaredLength > 0.000001 ? quaternion.normalized : reference, scale: scale / denominator) } + /// Blends morph weights by the same rules as poses: weighted by input weight and mask, with the rest weights filling any gap. + private func blendMorph(_ index: Int, joint: Int) -> [Float] { + let node = graph.nodes[index] + var total: Float = 0 + for edge in node.inputs.indices { total += node.inputs[edge].weight * masks[index][edge][joint] } + let denominator = max(1, total) + let restWeight = max(0, 1 - total) + var result = restMorph[joint].map { $0 * restWeight } + for edge in node.inputs.indices { + let weight = node.inputs[edge].weight * masks[index][edge][joint] + let input = morph[inputs[index][edge]][joint] + for target in result.indices { result[target] += input[target] * weight } + } + for target in result.indices { result[target] /= denominator } + return result + } + + /// Additive morph weights: the first input is the base, and each further input adds its offset from the rest weights. + private func additiveMorph(_ index: Int, joint: Int) -> [Float] { + let node = graph.nodes[index] + let rest = restMorph[joint] + let base = morph[inputs[index][0]][joint] + let amount = min(1, node.inputs[0].weight * masks[index][0][joint]) + var result = rest.indices.map { rest[$0] + (base[$0] - rest[$0]) * amount } + for edge in node.inputs.indices.dropFirst() { + let weight = min(1, node.inputs[edge].weight * masks[index][edge][joint]) + let input = morph[inputs[index][edge]][joint] + for target in result.indices { result[target] += (input[target] - rest[target]) * weight } + } + return result + } + private func additive(_ index: Int, joint: Int) -> SkeletalJointPose { let node = graph.nodes[index] let base = poses[inputs[index][0]][joint] diff --git a/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift b/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift new file mode 100644 index 000000000..0b88f87e7 --- /dev/null +++ b/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift @@ -0,0 +1,78 @@ +import Foundation + +/// A validated glTF-style track of morph target weights for one node: `targetCount` weights per key. +/// +/// Cubic keys store the in-tangents, the values and the out-tangents of every target, in that order. +public struct MorphWeightsTrack: Sendable { + public let nodeIndex: Int + public let interpolation: SkeletalAnimationTrack.Interpolation + public let times: [Double] + public let targetCount: Int + /// `times.count * targetCount` values, or three times that for cubic keys. + public let values: [Float] + + public init( + nodeIndex: Int, + interpolation: SkeletalAnimationTrack.Interpolation, + times: [Double], + targetCount: Int, + values: [Float] + ) throws(SkeletalAnimationError) { + let multiplier = interpolation == .cubicSpline ? 3 : 1 + guard nodeIndex >= 0, targetCount > 0, !times.isEmpty, times.allSatisfy({ $0.isFinite && $0 >= 0 }), + zip(times, times.dropFirst()).allSatisfy({ $0 < $1 }), + values.count == times.count * targetCount * multiplier, values.allSatisfy(\.isFinite), + interpolation != .cubicSpline || times.count >= 2 + else { throw .invalidTrack } + self.nodeIndex = nodeIndex + self.interpolation = interpolation + self.times = times + self.targetCount = targetCount + self.values = values + } + + /// Writes the weights at `time` into `output` (which must hold `targetCount` values) without allocating. + public func sample(at time: Double, into output: inout [Float]) { + guard output.count >= targetCount else { return } + guard time.isFinite, time > times[0] else { + for target in 0.. 1 { + let middle = (lower + upper) / 2 + if times[middle] <= time { lower = middle } else { upper = middle } + } + let span = Float(times[upper] - times[lower]) + let amount = Float((time - times[lower]) / (times[upper] - times[lower])) + let squared = amount * amount + let cubed = squared * amount + for target in 0.. Float { + interpolation == .cubicSpline ? values[(key * 3 + 1) * targetCount + target] : values[key * targetCount + target] + } +} diff --git a/Sources/AdaAnimation/Skeletal/SkeletalAnimation.swift b/Sources/AdaAnimation/Skeletal/SkeletalAnimation.swift index 1501d5790..d96e5897b 100644 --- a/Sources/AdaAnimation/Skeletal/SkeletalAnimation.swift +++ b/Sources/AdaAnimation/Skeletal/SkeletalAnimation.swift @@ -32,8 +32,11 @@ public struct SkeletalRig: Sendable { public let parentIndex: Int? public let restPose: SkeletalJointPose? public let restMatrix: Transform3D + /// The morph target weights of the mesh on this node when nothing animates them; `nil` for a node without morph targets. + public let morphRestWeights: [Float]? - public init(parentIndex: Int?, restPose: SkeletalJointPose?, restMatrix: Transform3D, name: String? = nil) { + public init(parentIndex: Int?, restPose: SkeletalJointPose?, restMatrix: Transform3D, name: String? = nil, morphRestWeights: [Float]? = nil) { + self.morphRestWeights = morphRestWeights self.name = name self.parentIndex = parentIndex self.restPose = restPose @@ -162,11 +165,14 @@ public struct SkeletalAnimationTrack: Sendable { public struct SkeletalAnimationClip: Sendable { public let name: String public let tracks: [SkeletalAnimationTrack] + /// Morph target weight tracks, which play alongside the pose tracks. + public let morphTracks: [MorphWeightsTrack] public let duration: Double - public init(name: String, tracks: [SkeletalAnimationTrack]) { + public init(name: String, tracks: [SkeletalAnimationTrack], morphTracks: [MorphWeightsTrack] = []) { self.name = name self.tracks = tracks - self.duration = tracks.compactMap { $0.times.last }.max() ?? 0 + self.morphTracks = morphTracks + self.duration = (tracks.compactMap { $0.times.last } + morphTracks.compactMap { $0.times.last }).max() ?? 0 } } diff --git a/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift b/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift index 4afbd55cb..7da81eff7 100644 --- a/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift +++ b/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift @@ -17,7 +17,10 @@ public struct SkeletalAnimationPlayer: Sendable { public private(set) var poses: [SkeletalJointPose] public private(set) var localTransforms: [Transform3D] public private(set) var globalTransforms: [Transform3D] + /// Morph target weights by rig node: the weights of the mesh on that node, or an empty array for a node without morph targets. + public private(set) var morphWeights: [[Float]] private var transitionSource: [SkeletalJointPose] = [] + private var transitionMorphSource: [[Float]] = [] private var transitionDuration: Double = 0 private var transitionTime: Double = 0 private var graphState: CompiledAnimationGraph? @@ -30,6 +33,8 @@ public struct SkeletalAnimationPlayer: Sendable { guard clips.allSatisfy({ clip in clip.tracks.allSatisfy { rig.nodes.indices.contains($0.nodeIndex) && rig.nodes[$0.nodeIndex].restPose != nil } + // A weights track must address a node whose mesh has exactly that many targets. + && clip.morphTracks.allSatisfy { rig.nodes.indices.contains($0.nodeIndex) && rig.nodes[$0.nodeIndex].morphRestWeights?.count == $0.targetCount } }) else { throw .invalidTrack } self.rig = rig @@ -37,6 +42,7 @@ public struct SkeletalAnimationPlayer: Sendable { self.poses = rig.nodes.map { $0.restPose ?? SkeletalJointPose() } self.localTransforms = rig.nodes.map(\.restMatrix) self.globalTransforms = Array(repeating: .identity, count: rig.nodes.count) + self.morphWeights = rig.nodes.map { $0.morphRestWeights ?? [] } evaluate() } @@ -52,6 +58,7 @@ public struct SkeletalAnimationPlayer: Sendable { graphState = nil pendingGraphEvents.removeAll(keepingCapacity: true) self.transitionSource = poses + self.transitionMorphSource = morphWeights self.transitionDuration = transitionDuration.isFinite ? max(0, transitionDuration) : 0 self.transitionTime = 0 self.clipIndex = index @@ -113,6 +120,7 @@ public struct SkeletalAnimationPlayer: Sendable { graphState = compiled clipIndex = nil transitionSource = poses + transitionMorphSource = morphWeights self.transitionDuration = transitionDuration.isFinite ? max(0, transitionDuration) : 0 transitionTime = 0 time = 0 @@ -156,13 +164,22 @@ public struct SkeletalAnimationPlayer: Sendable { } private mutating func evaluate() { - for index in rig.nodes.indices { poses[index] = rig.nodes[index].restPose ?? SkeletalJointPose() } + for index in rig.nodes.indices { + poses[index] = rig.nodes[index].restPose ?? SkeletalJointPose() + if let rest = rig.nodes[index].morphRestWeights { + // Element-wise, so the buffer is reused rather than reallocated every frame. + for target in rest.indices { morphWeights[index][target] = rest[target] } + } + } if graphState != nil { let graphTime = time let graphClips = clips graphState?.evaluate(time: graphTime, clips: graphClips) if let root = graphState?.root { for index in poses.indices { poses[index] = graphState?.poses[root][index] ?? poses[index] } + for index in morphWeights.indices where !morphWeights[index].isEmpty { + morphWeights[index] = graphState?.morph[root][index] ?? morphWeights[index] + } } } else if let clipIndex { let clip = clips[clipIndex] @@ -176,6 +193,9 @@ public struct SkeletalAnimationPlayer: Sendable { case .scale: poses[track.nodeIndex].scale = value.xyz } } + for track in clip.morphTracks { + track.sample(at: localTime, into: &morphWeights[track.nodeIndex]) + } } if transitionDuration > 0 && transitionTime < transitionDuration { let amount = Float(transitionTime / transitionDuration) @@ -185,8 +205,15 @@ public struct SkeletalAnimationPlayer: Sendable { poses[index].scale = source.scale + (poses[index].scale - source.scale) * amount poses[index].rotation = slerpQuat(source.rotation, poses[index].rotation, t: amount) } + for index in morphWeights.indices where transitionMorphSource.indices.contains(index) && transitionMorphSource[index].count == morphWeights[index].count { + for target in morphWeights[index].indices { + let from = transitionMorphSource[index][target] + morphWeights[index][target] = from + (morphWeights[index][target] - from) * amount + } + } } else { transitionSource.removeAll(keepingCapacity: true) + transitionMorphSource.removeAll(keepingCapacity: true) transitionDuration = 0 } rebuildTransforms() diff --git a/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift b/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift index cfe888b50..8ba17117d 100644 --- a/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift +++ b/Sources/AdaScene/3D/Components/MorphWeights3DComponent.swift @@ -10,14 +10,49 @@ import AdaECS /// A weight of 1 applies a target's full displacement and 0 none; values outside that range extrapolate. The renderer applies /// at most eight targets at once, the ones with the largest weights, and moves vertices before skinning. A model instantiated /// from glTF gets this component with the weights the file defines. -public struct MorphWeights3DComponent: Component { +/// +/// Scripts and the inspector cannot read arrays, so they steer one target at a time: pick it with ``selectedTarget`` or +/// ``selectedTargetName`` and set ``selectedTargetWeight``. Code can use ``weights`` and ``setWeight(_:named:)`` directly. +@Component +public struct MorphWeights3DComponent: Codable, Sendable { public var weights: [Float] /// Names of the targets in weight order, when the asset has them. public let targetNames: [String] + /// The target that ``selectedTargetName`` and ``selectedTargetWeight`` address. + public var selectedTarget: Int - public init(weights: [Float], targetNames: [String] = []) { + public init(weights: [Float], targetNames: [String] = [], selectedTarget: Int = 0) { self.weights = weights self.targetNames = targetNames + self.selectedTarget = selectedTarget + } + + private enum CodingKeys: CodingKey { case weights, targetNames, selectedTarget } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + weights = try container.decodeIfPresent([Float].self, forKey: .weights) ?? [] + targetNames = try container.decodeIfPresent([String].self, forKey: .targetNames) ?? [] + selectedTarget = try container.decodeIfPresent(Int.self, forKey: .selectedTarget) ?? 0 + } + + /// The number of targets. + public var targetCount: Int { weights.count } + + /// The name of the selected target; assigning a name selects that target. Empty when the target has no name. + public var selectedTargetName: String { + get { targetNames.indices.contains(selectedTarget) ? targetNames[selectedTarget] : "" } + set { + if let index = index(ofTarget: newValue) { selectedTarget = index } + } + } + + /// The weight of the selected target (0 when none is selected); assigning a finite value sets it. + public var selectedTargetWeight: Float { + get { weights.indices.contains(selectedTarget) ? weights[selectedTarget] : 0 } + set { + if weights.indices.contains(selectedTarget), newValue.isFinite { weights[selectedTarget] = newValue } + } } /// The index of the target with this name. diff --git a/Sources/AdaScene/3D/Model3DPlugin.swift b/Sources/AdaScene/3D/Model3DPlugin.swift index b0b4daf8a..5523adc6b 100644 --- a/Sources/AdaScene/3D/Model3DPlugin.swift +++ b/Sources/AdaScene/3D/Model3DPlugin.swift @@ -37,6 +37,7 @@ public struct Model3DPlugin: Plugin { ModelAnimation3DComponent.registerComponent() ModelNode3DComponent.registerComponent() SkinnedMesh3DComponent.registerComponent() + MorphWeights3DComponent.registerComponent() app.addSystem(SkeletalAnimation3DSystem.self, on: .postUpdate) VoxelAnimationPlayback3DComponent.registerComponent() VoxelTransformAnimation3DComponent.registerComponent() diff --git a/Sources/AdaScene/3D/ModelAsset3D+Animation.swift b/Sources/AdaScene/3D/ModelAsset3D+Animation.swift index 2165d2afb..839309d32 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Animation.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Animation.swift @@ -3,7 +3,13 @@ import AdaAssets import Math extension ModelAsset3D { - static func makeRig(nodes: [GLTFImportResult.Node]) throws -> SkeletalRig { + /// The weights a node's mesh has when nothing animates them, or `nil` when it has no morph targets. + private static func restWeights(of node: GLTFImportResult.Node, meshes: [GLTFImportResult.Mesh]) -> [Float]? { + guard let meshIndex = node.meshIndex, meshes.indices.contains(meshIndex), meshes[meshIndex].morphTargetCount > 0 else { return nil } + return node.weights ?? meshes[meshIndex].defaultWeights + } + + static func makeRig(nodes: [GLTFImportResult.Node], meshes: [GLTFImportResult.Mesh] = []) throws -> SkeletalRig { var parents = [Int?](repeating: nil, count: nodes.count) for (index, node) in nodes.enumerated() { for child in node.children { @@ -13,13 +19,23 @@ extension ModelAsset3D { } return try SkeletalRig(nodes: nodes.enumerated().map { index, node in let pose = node.restPose.map { SkeletalJointPose(translation: $0.translation, rotation: $0.rotation, scale: $0.scale) } - return SkeletalRig.Node(parentIndex: parents[index], restPose: pose, restMatrix: node.transform, name: node.name) + return SkeletalRig.Node( + parentIndex: parents[index], + restPose: pose, + restMatrix: node.transform, + name: node.name, + morphRestWeights: restWeights(of: node, meshes: meshes) + ) }) } - static func makeClips(_ clips: [GLTFImportResult.Animation]) throws -> [SkeletalAnimationClip] { + static func makeClips( + _ clips: [GLTFImportResult.Animation], + nodes: [GLTFImportResult.Node] = [], + meshes: [GLTFImportResult.Mesh] = [] + ) throws -> [SkeletalAnimationClip] { try clips.enumerated().map { index, clip in - // Morph weight channels are not pose tracks; they are played by the morph runtime, not by skeletal poses. + // Weight channels become morph tracks; the others are pose tracks. let tracks = try clip.channels.filter { $0.path != .weights }.map { channel in let sampler = clip.samplers[channel.samplerIndex] let path: SkeletalAnimationTrack.Path @@ -42,7 +58,24 @@ extension ModelAsset3D { nodeIndex: channel.nodeIndex, path: path, interpolation: interpolation, times: sampler.times.map(Double.init), values: values ) } - return SkeletalAnimationClip(name: clip.name ?? "Animation \(index)", tracks: tracks) + let morphTracks = try clip.channels.filter { $0.path == .weights }.map { channel in + let sampler = clip.samplers[channel.samplerIndex] + let interpolation: SkeletalAnimationTrack.Interpolation + switch sampler.interpolation { + case .linear: interpolation = .linear + case .step: interpolation = .step + case .cubicSpline: interpolation = .cubicSpline + } + let targets = nodes.indices.contains(channel.nodeIndex) ? Self.restWeights(of: nodes[channel.nodeIndex], meshes: meshes)?.count ?? 0 : 0 + return try MorphWeightsTrack( + nodeIndex: channel.nodeIndex, + interpolation: interpolation, + times: sampler.times.map(Double.init), + targetCount: targets, + values: sampler.output.values + ) + } + return SkeletalAnimationClip(name: clip.name ?? "Animation \(index)", tracks: tracks, morphTracks: morphTracks) } } } diff --git a/Sources/AdaScene/3D/ModelAsset3D.swift b/Sources/AdaScene/3D/ModelAsset3D.swift index 6df4a6a78..ec4882803 100644 --- a/Sources/AdaScene/3D/ModelAsset3D.swift +++ b/Sources/AdaScene/3D/ModelAsset3D.swift @@ -153,8 +153,8 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { throw AssetError.message("A skinned mesh supports at most \(Skinning3DUniforms.maximumJoints) joints.") } skins = result.skins - animationRig = result.skins.isEmpty && result.animations.isEmpty ? nil : try Self.makeRig(nodes: result.nodes) - animationClips = try Self.makeClips(result.animations) + animationRig = result.skins.isEmpty && result.animations.isEmpty ? nil : try Self.makeRig(nodes: result.nodes, meshes: result.meshes) + animationClips = try Self.makeClips(result.animations, nodes: result.nodes, meshes: result.meshes) let device = unsafe RenderEngine.shared.renderDevice let materials = try Self.makeMaterials(result) diff --git a/Sources/AdaScene/3D/SkeletalAnimation3D.swift b/Sources/AdaScene/3D/SkeletalAnimation3D.swift index cf2792f20..3c1802d81 100644 --- a/Sources/AdaScene/3D/SkeletalAnimation3D.swift +++ b/Sources/AdaScene/3D/SkeletalAnimation3D.swift @@ -90,6 +90,7 @@ public struct SkeletalAnimation3DSystem { @Query> private var models @Query> private var nodes @Query> private var skins + @Query> private var morphNodes @Res private var time @EventsSender private var animationEvents @Query private var cameras @@ -108,6 +109,8 @@ public struct SkeletalAnimation3DSystem { // These references are local to this update; no structural world mutations occur here. var nodesByRoot: [Entity.ID: [(Int, Ref)]] = [:] nodes.forEach { binding, transform in nodesByRoot[binding.modelRoot, default: []].append((binding.nodeIndex, transform)) } + var morphByRoot: [Entity.ID: [(Int, Ref)]] = [:] + morphNodes.forEach { binding, weights in morphByRoot[binding.modelRoot, default: []].append((binding.nodeIndex, weights)) } var skinsByRoot: [Entity.ID: [Ref]] = [:] skins.forEach { skin in skinsByRoot[skin.wrappedValue.modelRoot, default: []].append(skin) } models.forEach { root, animation in @@ -134,6 +137,14 @@ public struct SkeletalAnimation3DSystem { let next = Transform(rotation: pose.rotation, scale: pose.scale, position: pose.translation) if transform.wrappedValue != next { transform.wrappedValue = next } } + for (index, component) in morphByRoot[root.id] ?? [] { + guard value.player.morphWeights.indices.contains(index) else { continue } + let weights = value.player.morphWeights[index] + // Only nodes the animation drives (a matching, non-empty set of weights) are written. + if !weights.isEmpty, component.wrappedValue.weights.count == weights.count, component.wrappedValue.weights != weights { + component.wrappedValue.weights = weights + } + } for skin in skinsByRoot[root.id] ?? [] { skin.wrappedValue.updateMatrices(using: value.player) } value.appliedGeneration = value.player.poseGeneration value.appliedPlayer = value.player.instanceID diff --git a/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift b/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift new file mode 100644 index 000000000..f83db0a09 --- /dev/null +++ b/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift @@ -0,0 +1,207 @@ +import AdaAnimation +import Math +import Testing + +@Suite +struct MorphWeightsAnimationTests { + // MARK: Tracks + + @Test + func samplesLinearAndStepWeightsAndClampsAtTheEnds() throws { + let linear = try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [0, 1], targetCount: 2, values: [0, 1, 1, 0.5]) + var output: [Float] = [9, 9] + linear.sample(at: -3, into: &output) + #expect(output == [0, 1]) + linear.sample(at: 0.5, into: &output) + #expect(output == [0.5, 0.75]) + linear.sample(at: 4, into: &output) + #expect(output == [1, 0.5]) + let step = try MorphWeightsTrack(nodeIndex: 0, interpolation: .step, times: [0, 1], targetCount: 2, values: [0, 1, 1, 0.5]) + step.sample(at: 0.99, into: &output) + #expect(output == [0, 1]) + step.sample(at: 1, into: &output) + #expect(output == [1, 0.5]) + } + + @Test + func cubicWeightsUseTangentsScaledByTheKeySpan() throws { + // Two targets, two keys 2 seconds apart. Per key: in-tangent, value, out-tangent (each of both targets). + let values: [Float] = [ + 0, 0, /* in */ 0, 0, /* value */ 0, 0, /* out */ + 0, 0, /* in */ 1, 4, /* value */ 0, 0, /* out */ + ] + let cubic = try MorphWeightsTrack(nodeIndex: 0, interpolation: .cubicSpline, times: [0, 2], targetCount: 2, values: values) + var output: [Float] = [0, 0] + cubic.sample(at: 1, into: &output) + #expect(abs(output[0] - 0.5) < 0.00001 && abs(output[1] - 2) < 0.00001, "Flat tangents give the smoothstep midpoint.") + let sloped: [Float] = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0] + let withTangent = try MorphWeightsTrack(nodeIndex: 0, interpolation: .cubicSpline, times: [0, 2], targetCount: 2, values: sloped) + withTangent.sample(at: 1, into: &output) + #expect(abs(output[0] - 0.25) < 0.00001, "An out-tangent of 1 over a 2 second span adds a quarter at the midpoint.") + } + + @Test(arguments: ["noTargets", "noKeys", "times", "valueCount", "nonFinite", "cubicNeedsTwoKeys", "negativeNode"]) + func rejectsInvalidTracks(kind: String) { + #expect(throws: SkeletalAnimationError.invalidTrack) { + switch kind { + case "noTargets": _ = try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [0], targetCount: 0, values: []) + case "noKeys": _ = try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [], targetCount: 1, values: []) + case "times": _ = try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [1, 1], targetCount: 1, values: [0, 1]) + case "valueCount": _ = try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [0, 1], targetCount: 2, values: [0, 1, 2]) + case "nonFinite": _ = try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [0], targetCount: 1, values: [.nan]) + case "cubicNeedsTwoKeys": _ = try MorphWeightsTrack(nodeIndex: 0, interpolation: .cubicSpline, times: [0], targetCount: 1, values: [0, 0, 0]) + default: _ = try MorphWeightsTrack(nodeIndex: -1, interpolation: .linear, times: [0], targetCount: 1, values: [0]) + } + } + } + + // MARK: Player + + /// Node 0 carries a mesh with two targets resting at [0.2, 0.4]; node 1 has no morph targets. + private func makeRig() throws -> SkeletalRig { + try SkeletalRig(nodes: [ + .init(parentIndex: nil, restPose: SkeletalJointPose(), restMatrix: .identity, morphRestWeights: [0.2, 0.4]), + .init(parentIndex: 0, restPose: SkeletalJointPose(), restMatrix: .identity), + ]) + } + + private func clip(_ name: String, from: [Float], to: [Float], duration: Double = 1) throws -> SkeletalAnimationClip { + SkeletalAnimationClip(name: name, tracks: [], morphTracks: [ + try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [0, duration], targetCount: 2, values: from + to), + ]) + } + + private func makePlayer() throws -> SkeletalAnimationPlayer { + try SkeletalAnimationPlayer(rig: makeRig(), clips: [clip("Open", from: [0, 0], to: [1, 0.5]), clip("Close", from: [1, 1], to: [1, 1])]) + } + + @Test + func weightsStartAtTheRestWeightsAndOnlyMorphNodesHaveThem() throws { + let player = try makePlayer() + #expect(player.morphWeights == [[0.2, 0.4], []]) + } + + @Test + func aPlayingClipDrivesTheWeights() throws { + var player = try makePlayer() + try player.play("Open", transitionDuration: 0) + #expect(player.morphWeights[0] == [0, 0]) + player.seek(to: 0.5) + #expect(player.morphWeights[0] == [0.5, 0.25]) + player.advance(by: 0.25) + #expect(player.morphWeights[0] == [0.75, 0.375]) + #expect(player.morphWeights[1].isEmpty) + // The clip loops: 1.25 seconds in is 0.25 into the next cycle. + player.seek(to: 1.25) + #expect(player.morphWeights[0] == [0.25, 0.125]) + player.repeats = false + player.seek(to: 7) + #expect(player.morphWeights[0] == [1, 0.5], "A clip that does not repeat holds its last weights.") + } + + @Test + func playersKeepIndependentWeights() throws { + var first = try makePlayer() + let second = first + try first.play("Open", transitionDuration: 0) + first.repeats = false + first.seek(to: 1) + #expect(first.morphWeights[0] == [1, 0.5] && second.morphWeights[0] == [0.2, 0.4]) + } + + @Test + func aTransitionCrossfadesFromThePreviousWeights() throws { + var player = try makePlayer() + try player.play("Open", transitionDuration: 0) + player.repeats = false + player.seek(to: 1) + #expect(player.morphWeights[0] == [1, 0.5]) + try player.play("Close", transitionDuration: 1) + #expect(player.morphWeights[0] == [1, 0.5], "A fade that has not started shows the old weights.") + player.advance(by: 0.5) + #expect(abs(player.morphWeights[0][0] - 1) < 0.00001 && abs(player.morphWeights[0][1] - 0.75) < 0.00001) + player.advance(by: 1) + #expect(player.morphWeights[0] == [1, 1]) + } + + @Test + func tracksMustAddressNodesWithMatchingTargetCounts() throws { + let rig = try makeRig() + let wrongNode = SkeletalAnimationClip(name: "Bad", tracks: [], morphTracks: [ + try MorphWeightsTrack(nodeIndex: 1, interpolation: .linear, times: [0], targetCount: 2, values: [0, 0]), + ]) + #expect(throws: SkeletalAnimationError.invalidTrack) { try SkeletalAnimationPlayer(rig: rig, clips: [wrongNode]) } + let wrongCount = SkeletalAnimationClip(name: "Bad", tracks: [], morphTracks: [ + try MorphWeightsTrack(nodeIndex: 0, interpolation: .linear, times: [0], targetCount: 3, values: [0, 0, 0]), + ]) + #expect(throws: SkeletalAnimationError.invalidTrack) { try SkeletalAnimationPlayer(rig: rig, clips: [wrongCount]) } + } + + @Test + func weightTracksCountTowardTheClipDuration() throws { + #expect(try clip("Long", from: [0, 0], to: [1, 1], duration: 2.5).duration == 2.5) + } + + // MARK: Graph + + @Test + func aBlendNodeMixesWeightsLikePoses() throws { + var player = try makePlayer() + let graph = AnimationGraph(root: "Mix", nodes: [ + .init(id: "Open", clip: "Open"), .init(id: "Close", clip: "Close"), + .init(id: "Mix", kind: .blend, inputs: [.init(node: "Open", weight: 0.75), .init(node: "Close", weight: 0.25)]), + ]) + try player.play(graph, transitionDuration: 0) + player.seek(to: 0.5) + // Open at 0.5 is [0.5, 0.25] and Close is [1, 1]. + #expect(abs(player.morphWeights[0][0] - (0.5 * 0.75 + 1 * 0.25)) < 0.00001) + #expect(abs(player.morphWeights[0][1] - (0.25 * 0.75 + 1 * 0.25)) < 0.00001) + // Weights that add up to less than one let the rest weights fill the gap. + try player.setGraphWeight(0.5, node: "Mix", input: 0) + try player.setGraphWeight(0, node: "Mix", input: 1) + #expect(abs(player.morphWeights[0][0] - (0.5 * 0.5 + 0.2 * 0.5)) < 0.00001) + } + + @Test + func aMaskOnTheMeshNodeScalesItsWeights() throws { + var player = try makePlayer() + let graph = AnimationGraph(root: "Mix", nodes: [ + .init(id: "Open", clip: "Open"), .init(id: "Close", clip: "Close"), + .init(id: "Mix", kind: .blend, inputs: [ + .init(node: "Open"), + // The second input only reaches node 1, so it must leave the weights of node 0 alone. + .init(node: "Close", mask: .init(joints: [.init(nodeIndex: 1, includesDescendants: false)])), + ]), + ]) + try player.play(graph, transitionDuration: 0) + player.seek(to: 0.5) + #expect(abs(player.morphWeights[0][0] - 0.5) < 0.00001 && abs(player.morphWeights[0][1] - 0.25) < 0.00001) + } + + @Test + func anAdditiveNodeAddsOffsetsFromTheRestWeights() throws { + var player = try makePlayer() + let graph = AnimationGraph(root: "Layer", nodes: [ + .init(id: "Base", clip: "Open"), .init(id: "Extra", clip: "Close"), + .init(id: "Layer", kind: .additive, inputs: [.init(node: "Base"), .init(node: "Extra", weight: 0.5)]), + ]) + try player.play(graph, transitionDuration: 0) + player.seek(to: 0.5) + // Base [0.5, 0.25]; Extra [1, 1] is [0.8, 0.6] above the rest weights [0.2, 0.4], half of which is added. + #expect(abs(player.morphWeights[0][0] - (0.5 + 0.8 * 0.5)) < 0.00001) + #expect(abs(player.morphWeights[0][1] - (0.25 + 0.6 * 0.5)) < 0.00001) + } + + @Test + func aClipWithoutWeightTracksLeavesTheRestWeights() throws { + var player = try SkeletalAnimationPlayer( + rig: makeRig(), + clips: [SkeletalAnimationClip(name: "Still", tracks: [ + try SkeletalAnimationTrack(nodeIndex: 1, path: .translation, interpolation: .linear, times: [0, 1], values: [.zero, [1, 0, 0, 0]]), + ])] + ) + try player.play("Still", transitionDuration: 0) + player.seek(to: 0.5) + #expect(player.morphWeights[0] == [0.2, 0.4]) + } +} diff --git a/Tests/AdaEngineTests/Render3DMorphTargetTests.swift b/Tests/AdaEngineTests/Render3DMorphTargetTests.swift index 94aa649ed..f09f57344 100644 --- a/Tests/AdaEngineTests/Render3DMorphTargetTests.swift +++ b/Tests/AdaEngineTests/Render3DMorphTargetTests.swift @@ -52,6 +52,38 @@ struct Render3DMorphTargetSelectionTests { #expect(Set((0..<4).flatMap { model in (0..<4).map { Mesh3DRenderSource.morphKey(model: model, part: $0) } }).count == 16) } + @Test func componentRoundTripsThroughCodable() throws { + let component = MorphWeights3DComponent(weights: [0.25, 1], targetNames: ["smile", "blink"]) + let decoded = try JSONDecoder().decode(MorphWeights3DComponent.self, from: JSONEncoder().encode(component)) + #expect(decoded.weights == [0.25, 1] && decoded.targetNames == ["smile", "blink"]) + } + + @Test func scriptsAndTheInspectorSteerOneTargetAtATime() throws { + let descriptor = MorphWeights3DComponent.componentDescriptor + func field(_ key: String) -> ReflectedComponentField? { descriptor.fields.first { $0.key == key } } + #expect(field("selectedTarget")?.kind == .int && field("selectedTarget")?.isWritable == true) + #expect(field("selectedTargetName")?.kind == .string && field("selectedTargetName")?.isWritable == true) + #expect(field("selectedTargetWeight")?.kind == .float && field("selectedTargetWeight")?.isWritable == true) + #expect(field("weights")?.isWritable == false, "Arrays are not reflected as editable fields.") + + var component = MorphWeights3DComponent(weights: [0, 0.5, 0], targetNames: ["smile", "blink", "frown"]) + component.selectedTargetName = "blink" + #expect(component.selectedTarget == 1 && component.selectedTargetWeight == 0.5) + component.selectedTargetWeight = 0.9 + #expect(component.weights == [0, 0.9, 0]) + component.selectedTargetWeight = .nan + component.selectedTargetName = "unknown" + #expect(component.weights == [0, 0.9, 0] && component.selectedTarget == 1, "Bad input leaves the component alone.") + component.selectedTarget = 7 + component.selectedTargetWeight = 1 + #expect(component.weights == [0, 0.9, 0] && component.selectedTargetWeight == 0 && component.selectedTargetName.isEmpty) + #expect(component.targetCount == 3) + + // Written back through reflection, as the inspector and AdaScript do. + let written = try #require(field("selectedTargetWeight")?.write(MorphWeights3DComponent(weights: [0, 0], selectedTarget: 1), .double(0.75)) as? MorphWeights3DComponent) + #expect(written.weights == [0, 0.75]) + } + @Test func componentFindsTargetsByName() { var component = MorphWeights3DComponent(weights: [0, 0.5], targetNames: ["smile", "blink"]) #expect(component.weight(named: "blink") == 0.5 && component.weight(named: "frown") == nil) @@ -274,7 +306,7 @@ extension Render3DGPUTests { /// A one-quad glTF skinned to a single joint that is turned a quarter turn about Z, with one morph target that moves /// every vertex by +0.6 on X (mesh weight 1 by default). Written to a temporary file and loaded like any asset. - private static func skinnedMorphModel() throws -> ModelAsset3D { + private static func skinnedMorphModel(animated: Bool = false) throws -> ModelAsset3D { var bytes = Data() var views: [[String: Any]] = [] func add(_ data: Data) { @@ -296,19 +328,23 @@ extension Render3DGPUTests { add(floats([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0])) add(floats([0.6, 0, 0, 0.6, 0, 0, 0.6, 0, 0, 0.6, 0, 0])) add(floats([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])) + // Animation keys: the weight goes from 0 to 1 over one second. + add(floats([0, 1])) + add(floats([0, 1])) func accessor(_ view: Int, _ type: String, _ count: Int, _ component: Int = 5126) -> [String: Any] { ["bufferView": view, "componentType": component, "count": count, "type": type] } var positionAccessor = accessor(0, "VEC3", 4) positionAccessor["min"] = [-0.5, 0, 0] positionAccessor["max"] = [0.5, 1, 0] - let document: [String: Any] = [ + var document: [String: Any] = [ "asset": ["version": "2.0"], "buffers": [["byteLength": bytes.count, "uri": "data:application/octet-stream;base64," + bytes.base64EncodedString()]], "bufferViews": views, "accessors": [ positionAccessor, accessor(1, "VEC3", 4), accessor(2, "SCALAR", 6, 5123), accessor(3, "VEC4", 4, 5121), accessor(4, "VEC4", 4), accessor(5, "VEC3", 4), accessor(6, "MAT4", 1), + accessor(7, "SCALAR", 2), accessor(8, "SCALAR", 2), ], "materials": [["doubleSided": true, "pbrMetallicRoughness": ["baseColorFactor": [0.8, 0.4, 0.3, 1], "metallicFactor": 0]]], "meshes": [[ @@ -324,11 +360,48 @@ extension Render3DGPUTests { "scenes": [["nodes": [0, 1]]], "scene": 0, ] + if animated { + document["animations"] = [[ + "name": "Open", + "samplers": [["input": 7, "output": 8, "interpolation": "LINEAR"]], + "channels": [["sampler": 0, "target": ["node": 0, "path": "weights"]]], + ]] + } let url = FileManager.default.temporaryDirectory.appendingPathComponent("morph-skin-\(UUID().uuidString).gltf") try JSONSerialization.data(withJSONObject: document).write(to: url) return try #require(AssetsManager.loadSync(ModelAsset3D.self, at: url.path).asset) } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func animatedWeightsDriveTheMorphedMesh() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + let model = try Self.skinnedMorphModel(animated: true) + #expect(model.animationClips.map(\.name) == ["Open"] && model.animationClips[0].morphTracks.count == 1) + let root = model.instantiate(in: scene.app.main) + root.components[Transform.self] = Transform(position: [0, 0, -1.6]) + let mesh = try #require(Self.morphedEntity(under: root)) + var animation = try #require(root.components[ModelAnimation3DComponent.self]) + try animation.player.play("Open", transitionDuration: 0) + animation.player.repeats = false + animation.player.isPlaying = false + animation.player.seek(to: 0.5) + root.components[ModelAnimation3DComponent.self] = animation + let animated = try await scene.render(.none, frames: 4) + #expect(mesh.components[MorphWeights3DComponent.self]?.weights == [0.5], "The clip's weight at half a second must reach the entity.") + // The same weight set by hand, with the animation out of the way. + root.components[ModelAnimation3DComponent.self] = nil + var component = try #require(mesh.components[MorphWeights3DComponent.self]) + component.weights = [0.5] + mesh.components[MorphWeights3DComponent.self] = component + #expect(Self.matches(animated, try await scene.render(.none, frames: 4)), "Animated weights must draw like the same weights set directly.") + // And the end of the clip applies the full shift. + var finished = animation + finished.player.seek(to: 1) + root.components[ModelAnimation3DComponent.self] = finished + _ = try await scene.render(.none, frames: 2) + #expect(mesh.components[MorphWeights3DComponent.self]?.weights == [1]) + } + private static func morphedEntity(under root: Entity) -> Entity? { if root.components[MorphWeights3DComponent.self] != nil { return root } for child in root.children { From bdf505af218e7771c0e5067c7fbeda9bcb6129eb Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 14:24:23 +0300 Subject: [PATCH 10/11] 3D: authored morph weights on Model3DSource, kept unless a clip animates them Model3DSource gains morphWeights (target name to weight), applied at instantiation to every mesh that has a target of that name; scenes written before it decode to none. The animation system now writes player weights only to nodes some clip animates (SkeletalAnimationPlayer.animatedMorphNodes); before, a rigged model's rest weights from the file would have overwritten authored ones on the first frame. Tests cover the encoding and older scenes, authored weights surviving on a rigged model, and a clip taking over the weights it animates. Co-Authored-By: Claude Sonnet 5.5 --- .../Skeletal/SkeletalAnimationPlayer.swift | 3 ++ Sources/AdaScene/3D/Model3DSource.swift | 37 ++++++++++++++++++- Sources/AdaScene/3D/SkeletalAnimation3D.swift | 5 ++- .../MorphWeightsAnimationTests.swift | 7 ++++ .../Render3DMorphTargetTests.swift | 34 +++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift b/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift index 7da81eff7..e9cc830b0 100644 --- a/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift +++ b/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift @@ -19,6 +19,8 @@ public struct SkeletalAnimationPlayer: Sendable { public private(set) var globalTransforms: [Transform3D] /// Morph target weights by rig node: the weights of the mesh on that node, or an empty array for a node without morph targets. public private(set) var morphWeights: [[Float]] + /// The rig nodes whose morph weights any clip animates. Other nodes keep whatever weights their entity was given. + public let animatedMorphNodes: Set private var transitionSource: [SkeletalJointPose] = [] private var transitionMorphSource: [[Float]] = [] private var transitionDuration: Double = 0 @@ -43,6 +45,7 @@ public struct SkeletalAnimationPlayer: Sendable { self.localTransforms = rig.nodes.map(\.restMatrix) self.globalTransforms = Array(repeating: .identity, count: rig.nodes.count) self.morphWeights = rig.nodes.map { $0.morphRestWeights ?? [] } + self.animatedMorphNodes = Set(clips.flatMap { $0.morphTracks.map(\.nodeIndex) }) evaluate() } diff --git a/Sources/AdaScene/3D/Model3DSource.swift b/Sources/AdaScene/3D/Model3DSource.swift index b7af50b42..efd18f76a 100644 --- a/Sources/AdaScene/3D/Model3DSource.swift +++ b/Sources/AdaScene/3D/Model3DSource.swift @@ -37,9 +37,14 @@ public struct Model3DSource: Codable, Equatable, Sendable { public var lod: Model3DSourceLOD? /// Import settings for MagicaVoxel (`.vox`) sources; ignored for other formats. public var voxel: VoxelImportSettings? + /// Morph target weights by target name, applied to every mesh of the model that has a target of that name. They are the + /// weights the instance starts with and keeps unless an animation clip animates that mesh's weights. + public var morphWeights: [String: Float] public init(source: String = "", animation: String = "", autoplay: Bool = false, repeats: Bool = true, - animationGraph: AnimationGraph? = nil, lod: Model3DSourceLOD? = nil, voxel: VoxelImportSettings? = nil) { + animationGraph: AnimationGraph? = nil, lod: Model3DSourceLOD? = nil, voxel: VoxelImportSettings? = nil, + morphWeights: [String: Float] = [:]) { + self.morphWeights = morphWeights self.voxel = voxel self.source = source self.animation = animation @@ -49,6 +54,23 @@ public struct Model3DSource: Codable, Equatable, Sendable { self.lod = lod } + private enum CodingKeys: CodingKey { + case source, animation, autoplay, repeats, animationGraph, lod, voxel, morphWeights + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + source = try container.decode(String.self, forKey: .source) + animation = try container.decode(String.self, forKey: .animation) + autoplay = try container.decode(Bool.self, forKey: .autoplay) + repeats = try container.decode(Bool.self, forKey: .repeats) + animationGraph = try container.decodeIfPresent(AnimationGraph.self, forKey: .animationGraph) + lod = try container.decodeIfPresent(Model3DSourceLOD.self, forKey: .lod) + voxel = try container.decodeIfPresent(VoxelImportSettings.self, forKey: .voxel) + // Scenes written before morph targets have no weights. + morphWeights = try container.decodeIfPresent([String: Float].self, forKey: .morphWeights) ?? [:] + } + /// The path the model asset is loaded from: ``source`` plus the import settings of a `.vox` file as an asset query. public var assetPath: String { guard let voxel, !voxel.isDefault, !source.isEmpty, !source.contains("#"), @@ -74,6 +96,19 @@ public struct Model3DSource: Codable, Equatable, Sendable { try animationGraph.validate(rig: rig, clips: model.animationClips) } let root = model.instantiate(in: world) + if !morphWeights.isEmpty { + var pending = [root] + while let node = pending.popLast() { + if var component = node.components[MorphWeights3DComponent.self] { + var changed = false + for (name, weight) in morphWeights where weight.isFinite { + if component.setWeight(weight, named: name) { changed = true } + } + if changed { node.components[MorphWeights3DComponent.self] = component } + } + pending.append(contentsOf: node.children) + } + } if var animationComponent = root.components[ModelAnimation3DComponent.self], animationGraph != nil || !animation.isEmpty { if let animationGraph { try animationComponent.player.play(animationGraph, transitionDuration: 0) diff --git a/Sources/AdaScene/3D/SkeletalAnimation3D.swift b/Sources/AdaScene/3D/SkeletalAnimation3D.swift index 3c1802d81..3e4dc3818 100644 --- a/Sources/AdaScene/3D/SkeletalAnimation3D.swift +++ b/Sources/AdaScene/3D/SkeletalAnimation3D.swift @@ -138,9 +138,10 @@ public struct SkeletalAnimation3DSystem { if transform.wrappedValue != next { transform.wrappedValue = next } } for (index, component) in morphByRoot[root.id] ?? [] { - guard value.player.morphWeights.indices.contains(index) else { continue } + guard value.player.morphWeights.indices.contains(index), value.player.animatedMorphNodes.contains(index) else { continue } let weights = value.player.morphWeights[index] - // Only nodes the animation drives (a matching, non-empty set of weights) are written. + // Only nodes some clip animates are written, so weights authored on a model (Model3DSource.morphWeights) + // survive on the others. The weights must also match the node's targets. if !weights.isEmpty, component.wrappedValue.weights.count == weights.count, component.wrappedValue.weights != weights { component.wrappedValue.weights = weights } diff --git a/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift b/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift index f83db0a09..ba79c1732 100644 --- a/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift +++ b/Tests/AdaAnimationTests/MorphWeightsAnimationTests.swift @@ -137,6 +137,13 @@ struct MorphWeightsAnimationTests { #expect(throws: SkeletalAnimationError.invalidTrack) { try SkeletalAnimationPlayer(rig: rig, clips: [wrongCount]) } } + @Test + func onlyNodesSomeClipAnimatesAreReportedAsAnimated() throws { + #expect(try makePlayer().animatedMorphNodes == [0]) + let still = try SkeletalAnimationPlayer(rig: makeRig(), clips: [SkeletalAnimationClip(name: "Still", tracks: [])]) + #expect(still.animatedMorphNodes.isEmpty, "A rig whose clips animate no weights must leave entity weights alone.") + } + @Test func weightTracksCountTowardTheClipDuration() throws { #expect(try clip("Long", from: [0, 0], to: [1, 1], duration: 2.5).duration == 2.5) diff --git a/Tests/AdaEngineTests/Render3DMorphTargetTests.swift b/Tests/AdaEngineTests/Render3DMorphTargetTests.swift index f09f57344..aa5d38a8a 100644 --- a/Tests/AdaEngineTests/Render3DMorphTargetTests.swift +++ b/Tests/AdaEngineTests/Render3DMorphTargetTests.swift @@ -84,6 +84,15 @@ struct Render3DMorphTargetSelectionTests { #expect(written.weights == [0, 0.75]) } + @Test func modelSourceKeepsAuthoredMorphWeightsAndDecodesOlderScenes() throws { + let source = Model3DSource(source: "model.glb", morphWeights: ["smile": 0.5, "blink": 1]) + let decoded = try JSONDecoder().decode(Model3DSource.self, from: JSONEncoder().encode(source)) + #expect(decoded == source && decoded.morphWeights == ["smile": 0.5, "blink": 1]) + let older = try JSONDecoder().decode(Model3DSource.self, from: Data(#"{"source":"a.glb","animation":"","autoplay":false,"repeats":true}"#.utf8)) + #expect(older.morphWeights.isEmpty && older.source == "a.glb") + #expect(Model3DSource(source: "a.glb") != source && Model3DSource(source: "model.glb", morphWeights: ["smile": 0.6]) != source) + } + @Test func componentFindsTargetsByName() { var component = MorphWeights3DComponent(weights: [0, 0.5], targetNames: ["smile", "blink"]) #expect(component.weight(named: "blink") == 0.5 && component.weight(named: "frown") == nil) @@ -402,6 +411,31 @@ extension Render3DGPUTests { #expect(mesh.components[MorphWeights3DComponent.self]?.weights == [1]) } + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func authoredWeightsStartTheInstanceAndYieldToAnimation() async throws { + let scene = try await Render3DDebugViewGPUTests.makeScene() + // A rigged model whose clips do not animate weights: the authored weight must survive the animation system. + let still = try Self.skinnedMorphModel() + // The owner carries the same reference, as in a scene; otherwise the source system treats the instance as stale. + let reference = Model3DSource(source: "x", morphWeights: ["shift": 0.4, "unknown": 1]) + let owner = scene.app.main.spawn("Owner") + owner.components[Model3DSource.self] = reference + let root = try reference.instantiate(still, in: scene.app.main, under: owner) + let mesh = try #require(Self.morphedEntity(under: root)) + #expect(mesh.components[MorphWeights3DComponent.self]?.weights == [0.4], "A name the model does not have is ignored.") + _ = try await scene.render(.none, frames: 4) + #expect(mesh.components[MorphWeights3DComponent.self]?.weights == [0.4], "Rest weights from the player must not overwrite authored ones.") + // A model whose clip animates the weights: the clip takes over on the first pose. + let animated = try Self.skinnedMorphModel(animated: true) + let animatedReference = Model3DSource(source: "y", animation: "Open", autoplay: false, morphWeights: ["shift": 0.4]) + let animatedOwner = scene.app.main.spawn("Animated owner") + animatedOwner.components[Model3DSource.self] = animatedReference + let animatedRoot = try animatedReference.instantiate(animated, in: scene.app.main, under: animatedOwner) + let animatedMesh = try #require(Self.morphedEntity(under: animatedRoot)) + _ = try await scene.render(.none, frames: 4) + #expect(animatedMesh.components[MorphWeights3DComponent.self]?.weights == [0], "A clip that animates the weights owns them.") + } + private static func morphedEntity(under root: Entity) -> Entity? { if root.components[MorphWeights3DComponent.self] != nil { return root } for child in root.children { From 0fb989c63c9279d390c5d1a28861ccdc9645ddcb Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 19 Oct 2026 15:58:33 +0300 Subject: [PATCH 11/11] Editor: Morph Targets panel for Model 3D, with live preview and undoable apply The Animator panel gains a Morph Targets mode that lists the model's named targets with a slider each, shows the result live in the model preview, and applies it to the scene as one undoable transaction stored as a structured morphWeights map on the Model 3D component (a weight equal to the file's default is not an override; clearing removes the key). The Model 3D descriptor decodes the map, EditorComponentRegistry registers MorphWeights3DComponent, and the strings are localized (en, ru). Six Editor tests cover the panel model, the payload, the scene transaction with save, undo and redo, the preview, and the weight list; one more opens the saved scene through the scene loader and is not run on machines where that loader ends the test process (the existing animation graph round trip does the same there). Records the design and gaps in ADR-0027. Co-Authored-By: Claude Sonnet 5.5 --- .../0027-3d-rendering-pipeline-gap-roadmap.md | 27 +- .../AdaEditor/Assets/Localization/en.json | 11 +- .../AdaEditor/Assets/Localization/ru.json | 11 +- .../AdaEditor/EditorComponentRegistry.swift | 1 + .../EditorModel3DComponentDescriptor.swift | 3 +- .../AdaEditor/EditorMorphTargetsModel.swift | 107 ++++++++ .../UI/Editor/EditorAnimationPanel.swift | 12 +- .../UI/Editor/EditorModelAssetPreview.swift | 16 ++ .../UI/Editor/EditorMorphTargetsPanel.swift | 144 +++++++++++ .../EditorMorphTargetTests.swift | 232 ++++++++++++++++++ 10 files changed, 556 insertions(+), 8 deletions(-) create mode 100644 Editor/Sources/AdaEditor/EditorMorphTargetsModel.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorMorphTargetsPanel.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorMorphTargetTests.swift diff --git a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md index 82d168498..31703c4b9 100644 --- a/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md +++ b/Documentation/ArchitectureDecisions/0027-3d-rendering-pipeline-gap-roadmap.md @@ -304,7 +304,7 @@ Each item needs its own short requirements note before implementation. - [x] Stage 3: morph-target asset, glTF import and GPU evaluation (see "Stage 3, first slice"). - [x] Stage 3: morph with skinning, shadows, motion vectors and bounds. - [x] Stage 3: animated weights (clips, crossfades and the animation graph) and AdaScript access (see "Stage 3, second slice"). -- [ ] Stage 3: Editor panel for morph weights. +- [x] Stage 3: Editor panel for morph weights (see "Stage 3, third slice"). - [ ] Stage 4: OIT mode with sorted fallback. - [ ] Stage 4: transmission/volume pass. - [x] Stage 5: distance/height fog (`Fog3D` in `Environment3D`, applied in the composite pass; see "Stage 5, first slice"). @@ -953,6 +953,31 @@ and Codable tests cover the component. Not done: an Editor panel (the Editor package keeps its own component catalog and builds separately) and animating weights from keyframe `AdaAnimation` clips (only glTF clips and the skeletal graph drive weights); an AdaScript sample. +### Stage 3, third slice: authored weights and the Editor panel (2026-10-19) + +**Where weights live in a scene.** A model in a scene is a `Model3DSource` that instantiates a hierarchy at run time, so the weight +components exist only on generated children. `Model3DSource.morphWeights` (target name to weight, absent in older scenes) is applied at +instantiation to every mesh with a target of that name, which is what an Editor can author. They are the weights the instance +starts with and keeps unless a clip animates that mesh's weights: `SkeletalAnimation3DSystem` now writes player weights only to +nodes some clip animates (`SkeletalAnimationPlayer.animatedMorphNodes`). Before that, a rigged model's rest weights from the file +would have overwritten authored ones on the first frame. + +**Editor.** The Animator panel has a Morph Targets mode next to the skeletal graph and keyframes. It loads the model, lists its *named* +targets (a model's unnamed targets are counted and reported as not addressable from a scene) with one slider each from 0 to 1, +shows the result live in the preview (`EditorModelAssetPreviewModel.morphWeights`), and applies it as one undoable scene transaction +(`setMorphWeights`), stored as a structured `morphWeights` map on the Model 3D component (a weight equal to the file's default is not an +override, and an empty map removes the key). Reset clears the overrides. Names the model does not have are dropped when it loads. + +**Tests (Editor package, built with Xcode's toolchain).** Six run and pass: the model (targets, defaults, clamping, unnamed targets), the payload, +a scene transaction that saves, reopens, decodes through the component registry, undoes, redoes and clears, the preview applying and +undoing weights, and the weight list drawing one named slider per target. A seventh (`loaderAppliesAuthoredWeights`) opens the saved scene +through `EditorSceneFileLoader` and checks the instantiated entity's weights; it did not run here, because on this machine any Editor test that +goes through that loader ends the test process silently, the existing `EditorAnimationGraphTests` scene round trip included. The same behavior is +covered on the runtime side by `authoredWeightsStartTheInstanceAndYieldToAnimation`. + +Not done: slider interaction is covered through the model and workbench, not by driving the slider with simulated pointer events; the Environment 3D inspector does not yet list the fog, light-shaft, tiled-light or shadow-cache settings added earlier in this work; weights +cannot be keyframed in the Editor's keyframe animator (only glTF clips and the skeletal graph animate them). + ## Validation requirements Use Swift Testing for logic (settings decoding, extension gating, budgets, diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json index 721a5f691..9b80a0dc8 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/en.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json @@ -1655,5 +1655,14 @@ "Save annotation": "Save annotation", "Edit annotation #{0}: {1}": "Edit annotation #{0}: {1}", "Ready to send together: {0}": "Ready to send together: {0}", - "This note was edited in the composer. Edit its text there or remove it before replacing it.": "This note was edited in the composer. Edit its text there or remove it before replacing it." + "This note was edited in the composer. Edit its text there or remove it before replacing it.": "This note was edited in the composer. Edit its text there or remove it before replacing it.", + "Morph Targets": "Morph Targets", + "MORPH TARGETS": "MORPH TARGETS", + "Loading morph target model…": "Loading morph target model…", + "This model has no named morph targets.": "This model has no named morph targets.", + "Targets without a name cannot be set from a scene.": "Targets without a name cannot be set from a scene.", + "Applied to scene. Save the scene to keep these weights.": "Applied to scene. Save the scene to keep these weights.", + "Morph weights cleared.": "Morph weights cleared.", + "Morph weights applied": "Morph weights applied", + "Morph weights cleared": "Morph weights cleared" } diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json index 69ec835ec..810a0e3c8 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json @@ -1655,5 +1655,14 @@ "Save annotation": "Сохранить аннотацию", "Edit annotation #{0}: {1}": "Редактировать аннотацию №{0}: {1}", "Ready to send together: {0}": "Будут отправлены вместе: {0}", - "This note was edited in the composer. Edit its text there or remove it before replacing it.": "Текст замечания изменён в композере. Отредактируйте его там или удалите аннотацию перед заменой." + "This note was edited in the composer. Edit its text there or remove it before replacing it.": "Текст замечания изменён в композере. Отредактируйте его там или удалите аннотацию перед заменой.", + "Morph Targets": "Цели морфинга", + "MORPH TARGETS": "ЦЕЛИ МОРФИНГА", + "Loading morph target model…": "Загрузка модели с целями морфинга…", + "This model has no named morph targets.": "У этой модели нет именованных целей морфинга.", + "Targets without a name cannot be set from a scene.": "Цели без имени нельзя задать из сцены.", + "Applied to scene. Save the scene to keep these weights.": "Применено к сцене. Сохраните сцену, чтобы сохранить эти веса.", + "Morph weights cleared.": "Веса морфинга сброшены.", + "Morph weights applied": "Веса морфинга применены", + "Morph weights cleared": "Веса морфинга сброшены" } diff --git a/Editor/Sources/AdaEditor/EditorComponentRegistry.swift b/Editor/Sources/AdaEditor/EditorComponentRegistry.swift index a9eb6d2a8..51125bfd4 100644 --- a/Editor/Sources/AdaEditor/EditorComponentRegistry.swift +++ b/Editor/Sources/AdaEditor/EditorComponentRegistry.swift @@ -322,6 +322,7 @@ enum EditorComponentRegistry { AssetsManager.registerAssetType(SpineSkeleton.self) RuntimeTypeRegistry.registerComponent(AudioSource.self, names: ["AudioSource"], makeDefault: { AudioSource() }) ModelAnimationPlayback3DComponent.registerComponent() + MorphWeights3DComponent.registerComponent() CharacterController3DComponent.registerComponent() CameraCollision3DComponent.registerComponent() RuntimeTypeRegistry.registerComponent(UIComponent.self, names: ["UIComponent"]) diff --git a/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift b/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift index 2c13ad60e..aab35049a 100644 --- a/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift +++ b/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift @@ -29,7 +29,8 @@ extension EditorComponentRegistry { if case .null = value { return nil } return try JSONDecoder().decode(Model3DSourceLOD.self, from: Data(value.jsonString.utf8)) }, - voxel: voxelSettings(in: payload) + voxel: voxelSettings(in: payload), + morphWeights: EditorMorphWeightsPayload.decode(payload["morphWeights"]) ) } ) diff --git a/Editor/Sources/AdaEditor/EditorMorphTargetsModel.swift b/Editor/Sources/AdaEditor/EditorMorphTargetsModel.swift new file mode 100644 index 000000000..bf3fe07e9 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorMorphTargetsModel.swift @@ -0,0 +1,107 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Observation + +/// Scene payloads keep morph weights as a structured map from target name to weight, the shape `Model3DSource.morphWeights` has. +enum EditorMorphWeightsPayload { + static func decode(_ value: EditorSceneValue?) -> [String: Float] { + guard case let .object(entries)? = value else { + return [:] + } + var weights: [String: Float] = [:] + for (name, entry) in entries { + if let number = entry.doubleValue, number.isFinite { weights[name] = Float(number) } + } + return weights + } + + static func encode(_ weights: [String: Float]) -> EditorSceneValue { + .object(weights.mapValues { .double(Double($0)) }) + } +} + +/// The named morph targets of a model and the weights an author has set for them. +@Observable +@MainActor +final class EditorMorphTargetsModel { + struct Target: Equatable, Identifiable { + let name: String + /// The weight the file gives the target when nothing overrides it. + let defaultWeight: Float + var id: String { name } + } + + /// The slider range. glTF weights are normally 0 to 1; the renderer extrapolates outside that. + static let weightRange: ClosedRange = 0...1 + + private(set) var targets: [Target] = [] + /// Targets of the model that have no name; they cannot be addressed from a scene. + private(set) var unnamedTargetCount = 0 + private(set) var weights: [String: Float] = [:] + + func configure(asset: ModelAsset3D, authored: [String: Float]) { + var seen: [String: Float] = [:] + var order: [String] = [] + var unnamed = 0 + for (index, mesh) in asset.meshes.enumerated() where mesh.hasMorphTargets { + let names = mesh.morphTargetNames + let defaults = asset.meshMorphWeights.indices.contains(index) ? asset.meshMorphWeights[index] : [] + for target in 0.. Float { + weights[target.name] ?? target.defaultWeight + } + + func setWeight(_ value: Float, for target: Target) { + guard value.isFinite else { return } + let clamped = min(max(value, Self.weightRange.lowerBound), Self.weightRange.upperBound) + if clamped == target.defaultWeight { + weights.removeValue(forKey: target.name) + } else { + weights[target.name] = clamped + } + } + + /// Clears every authored weight, so the model's own defaults apply. + func reset() { + weights.removeAll() + } + + var hasAuthoredWeights: Bool { !weights.isEmpty } +} + +extension EditorWorkbenchViewModel { + /// One scene-history transaction that sets the weights a Model 3D reference starts with; an empty map removes them. + @discardableResult + func setMorphWeights(_ weights: [String: Float], target: EditorSkeletalAnimationTarget) -> Bool { + guard let document = sceneDocument(id: target.documentID), !document.isReadOnly, + document.sceneModel?.entities.contains(where: { $0.id == target.entityID }) == true + else { return false } + updateSceneModelDocument(id: target.documentID, status: weights.isEmpty ? "Morph weights cleared" : "Morph weights applied") { scene in + guard let index = scene.entities.firstIndex(where: { $0.id == target.entityID }), + scene.entities[index].components[EditorBuiltInComponentType.model3DSource] != nil + else { return } + if weights.isEmpty { + scene.entities[index].components[EditorBuiltInComponentType.model3DSource]?.removeValue(forKey: "morphWeights") + } else { + scene.entities[index].components[EditorBuiltInComponentType.model3DSource]?["morphWeights"] = EditorMorphWeightsPayload.encode(weights) + } + } + return true + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift index e1d715dab..70f67ddbc 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift @@ -4,7 +4,8 @@ import Math struct EditorAnimationPanel: View { let viewModel: EditorViewModel - @State private var usesSkeletalGraph = true + private enum Mode { case skeletalGraph, morphTargets, keyframes } + @State private var mode = Mode.skeletalGraph @Environment(\.metrics) private var metrics @Environment(\.theme) private var theme @@ -13,12 +14,15 @@ struct EditorAnimationPanel: View { VStack(alignment: .leading, spacing: 0) { if let target = viewModel.skeletalAnimationTarget { HStack(spacing: 8) { - cycleButton(title: "Skeletal Graph", identifier: "AdaEditor.Animator.GraphMode") { usesSkeletalGraph = true } - cycleButton(title: "Keyframes", identifier: "AdaEditor.Animator.KeyframeMode") { usesSkeletalGraph = false } + cycleButton(title: "Skeletal Graph", identifier: "AdaEditor.Animator.GraphMode") { mode = .skeletalGraph } + cycleButton(title: "Morph Targets", identifier: "AdaEditor.Animator.MorphMode") { mode = .morphTargets } + cycleButton(title: "Keyframes", identifier: "AdaEditor.Animator.KeyframeMode") { mode = .keyframes } Spacer() }.padding(6) - if usesSkeletalGraph { + if mode == .skeletalGraph { EditorSkeletalAnimatorPanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID) + } else if mode == .morphTargets { + EditorMorphTargetsPanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID) } else { panelToolbar panelContent diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift index b0ca62a74..11c24f0fc 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift @@ -14,6 +14,9 @@ final class EditorModelAssetPreviewModel { var playsAnimation = true { didSet { updatePlayback() } } var animationGraph: AnimationGraph? { didSet { updatePlayback() } } var graphEventLog: [String] = [] + /// Morph target weights by name that the preview shows; every target the entity has is set to its entry, so a target removed from + /// the map needs its default put back by the caller. + var morphWeights: [String: Float] = [:] { didSet { applyMorphWeights() } } @ObservationIgnored private var loadTask: Task? @ObservationIgnored private var normalizationCache: (ObjectIdentifier, Transform, AABB)? @ObservationIgnored private weak var world: World? @@ -56,6 +59,19 @@ final class EditorModelAssetPreviewModel { animationRoot = world.getEntities().first { $0.components[ModelAnimation3DComponent.self] != nil }?.id voxelRoot = world.getEntities().first { $0.components[VoxelAnimationPlayback3DComponent.self] != nil }?.id updatePlayback() + applyMorphWeights() + } + + private func applyMorphWeights() { + guard let world, !morphWeights.isEmpty else { return } + for entity in world.getEntities() { + guard var component = entity.components[MorphWeights3DComponent.self] else { continue } + var changed = false + for (name, weight) in morphWeights where weight.isFinite { + if component.weight(named: name) != weight, component.setWeight(weight, named: name) { changed = true } + } + if changed { entity.components[MorphWeights3DComponent.self] = component } + } } /// Whether the previewed asset is a MagicaVoxel scene with animation to play. diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorMorphTargetsPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorMorphTargetsPanel.swift new file mode 100644 index 000000000..c2de3c9b9 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorMorphTargetsPanel.swift @@ -0,0 +1,144 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// One slider row per named morph target. Shared by the Animator panel and its tests. +struct EditorMorphWeightList: View { + let model: EditorMorphTargetsModel + var onChange: () -> Void = {} + @Environment(\.theme) private var theme + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(model.targets, id: \.name) { target in + row(target) + } + } + .padding(4) + } + .accessibilityIdentifier("AdaEditor.Morph.List") + } + + private func row(_ target: EditorMorphTargetsModel.Target) -> some View { + HStack(spacing: 8) { + Text(target.name) + .font(.system(size: 11, weight: model.weights[target.name] == nil ? .regular : .semibold)) + .frame(width: 130, alignment: .leading) + .accessibilityIdentifier("AdaEditor.Morph.Name.\(target.name)") + Slider( + value: Binding( + get: { Double(model.weight(of: target)) }, + set: { value in + model.setWeight(Float(value), for: target) + onChange() + } + ), + in: Double(EditorMorphTargetsModel.weightRange.lowerBound)...Double(EditorMorphTargetsModel.weightRange.upperBound) + ) + .accessibilityIdentifier("AdaEditor.Morph.Slider.\(target.name)") + Text(String(format: "%.2f", model.weight(of: target))) + .font(.system(size: 11)).foregroundColor(theme.editorColors.muted) + .frame(width: 38, alignment: .trailing) + } + } +} + +/// Sets the morph weights a Model 3D reference starts with, with a live preview. Applying is one undoable scene transaction. +struct EditorMorphTargetsPanel: View { + let viewModel: EditorViewModel + let target: EditorSkeletalAnimationTarget + @State private var model = EditorMorphTargetsModel() + @State private var preview = EditorModelAssetPreviewModel() + @State private var camera = EditorModelPreviewCamera() + @State private var status = "" + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + toolbar + if preview.isLoading { + Text(L10n.text("Loading morph target model…")).font(.system(size: 12)) + } else if let error = preview.error { + Text(error).font(.system(size: 12)).foregroundColor(.red) + } else if preview.asset != nil { + if model.targets.isEmpty { + Text(L10n.text("This model has no named morph targets.")) + .font(.system(size: 12)).foregroundColor(theme.editorColors.muted) + .accessibilityIdentifier("AdaEditor.Morph.Empty") + } else { + HStack(spacing: 8) { + EditorModelPreviewViewport(model: preview, camera: camera) + .frame(width: 220, height: 150) + EditorMorphWeightList(model: model, onChange: { syncPreview() }) + } + .frame(maxHeight: .infinity) + } + if model.unnamedTargetCount > 0 { + Text(L10n.text("Targets without a name cannot be set from a scene.")) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + } + } + if !status.isEmpty { Text(status).font(.system(size: 11)).foregroundColor(theme.editorColors.muted) } + } + .padding(8) + .onAppear { load() } + .onDisappear { preview.cancel() } + .onChange(of: target.source) { old, new in + if old.source != new.source { load() } else { configure() } + } + .onChange(of: preview.asset.map(ObjectIdentifier.init)) { _, _ in configure() } + .accessibilityIdentifier("AdaEditor.Animator.MorphTargets") + } + + private var readOnly: Bool { viewModel.workbench.activeSceneDocument?.isReadOnly == true } + + private var toolbar: some View { + HStack(spacing: 8) { + Text(L10n.text("MORPH TARGETS")).font(.system(size: 11, weight: .semibold)) + Spacer() + action("Apply to Scene", id: "Apply") { + if viewModel.workbench.setMorphWeights(model.weights, target: target) { + status = L10n.tr("Applied to scene. Save the scene to keep these weights.") + } + } + .disabled(readOnly || model.targets.isEmpty || model.weights == target.source.morphWeights) + action("Reset", id: "Reset") { + model.reset() + syncPreview() + if viewModel.workbench.setMorphWeights([:], target: target) { + status = L10n.tr("Morph weights cleared.") + } + } + .disabled(readOnly || (!model.hasAuthoredWeights && target.source.morphWeights.isEmpty)) + } + } + + private func action(_ title: String, id: String, _ action: @escaping () -> Void) -> some View { + Button(action: action) { Text(L10n.tr(title)).font(.system(size: 11)).padding(4) } + .buttonStyle(DefaultButtonStyle()) + .accessibilityIdentifier("AdaEditor.Morph.\(id)") + } + + private func load() { + do { + let url = try EditorModelResource.resolve(target.source.source, sourceURL: target.sceneURL, resourceRootURL: viewModel.projectAssetsURL) + preview.load(path: url.path) + } catch { + preview.isLoading = false + preview.error = error.localizedDescription + } + } + + private func configure() { + guard let asset = preview.asset else { + return + } + model.configure(asset: asset, authored: target.source.morphWeights) + syncPreview() + } + + /// Every target gets its shown weight, so a reset puts the defaults back. + private func syncPreview() { + preview.morphWeights = Dictionary(uniqueKeysWithValues: model.targets.map { ($0.name, model.weight(of: $0)) }) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMorphTargetTests.swift b/Editor/Tests/AdaEditorTests/EditorMorphTargetTests.swift new file mode 100644 index 000000000..33ec2e414 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorMorphTargetTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Math +import Testing + +@_spi(Internal) @testable import AdaApp +@testable import AdaEditor +@_spi(AdaEngine) @testable import AdaEngine +@_spi(Internal) @testable import AdaRender +@_spi(Internal) import AdaUI +import AdaUtils + +@MainActor +@Suite(.serialized) +struct EditorMorphTargetTests { + init() throws { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } + GLTFLoaderResolver.shared.setLoader(NativeGLTFLoader()) + EditorComponentRegistry.registerBuiltIns() + } + + /// A one-quad glTF with two named morph targets ("smile" resting at 0.25, "blink" at 0) written into `directory`. + private func writeModel(in directory: URL, targetNames: [String]? = ["smile", "blink"]) throws -> URL { + var bytes = Data() + var views: [[String: Any]] = [] + func add(_ data: Data) { + views.append(["buffer": 0, "byteOffset": bytes.count, "byteLength": data.count]) + bytes.append(data) + while bytes.count % 4 != 0 { bytes.append(0) } + } + func floats(_ values: [Float]) -> Data { + var data = Data() + for value in values { withUnsafeBytes(of: value.bitPattern.littleEndian) { data.append(contentsOf: $0) } } + return data + } + add(floats([-0.5, 0, 0, 0.5, 0, 0, -0.5, 1, 0, 0.5, 1, 0])) + var indices = Data() + for index in [0, 1, 2, 2, 1, 3] as [UInt16] { withUnsafeBytes(of: index.littleEndian) { indices.append(contentsOf: $0) } } + add(indices) + add(floats([0.3, 0, 0, 0.3, 0, 0, 0.3, 0, 0, 0.3, 0, 0])) + add(floats([0, 0.3, 0, 0, 0.3, 0, 0, 0.3, 0, 0, 0.3, 0])) + func accessor(_ view: Int, _ type: String, _ count: Int, _ component: Int = 5126) -> [String: Any] { + ["bufferView": view, "componentType": component, "count": count, "type": type] + } + var positions = accessor(0, "VEC3", 4) + positions["min"] = [-0.5, 0, 0] + positions["max"] = [0.5, 1, 0] + var mesh: [String: Any] = [ + "weights": [0.25, 0], + "primitives": [["attributes": ["POSITION": 0], "indices": 1, "targets": [["POSITION": 2], ["POSITION": 3]]]], + ] + if let targetNames { mesh["extras"] = ["targetNames": targetNames] } + let document: [String: Any] = [ + "asset": ["version": "2.0"], + "buffers": [["byteLength": bytes.count, "uri": "data:application/octet-stream;base64," + bytes.base64EncodedString()]], + "bufferViews": views, + "accessors": [positions, accessor(1, "SCALAR", 6, 5123), accessor(2, "VEC3", 4), accessor(3, "VEC3", 4)], + "meshes": [mesh], + "nodes": [["mesh": 0]], + "scenes": [["nodes": [0]]], + "scene": 0, + ] + let url = directory.appendingPathComponent("Face.gltf") + try JSONSerialization.data(withJSONObject: document).write(to: url) + return url + } + + private func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MorphTargets-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func loadAsset(_ url: URL) throws -> ModelAsset3D { + try #require(AssetsManager.loadSync(ModelAsset3D.self, at: url.path).asset) + } + + @Test("The model lists named targets with the file's default weights and drops authored names the model lacks") + func listsTargets() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let asset = try loadAsset(writeModel(in: directory)) + let model = EditorMorphTargetsModel() + model.configure(asset: asset, authored: ["smile": 0.7, "unknown": 1]) + #expect(model.targets == [.init(name: "smile", defaultWeight: 0.25), .init(name: "blink", defaultWeight: 0)]) + #expect(model.unnamedTargetCount == 0) + #expect(model.weights == ["smile": 0.7], "A name the model does not have is dropped.") + #expect(model.weight(of: model.targets[0]) == 0.7 && model.weight(of: model.targets[1]) == 0) + + let unnamed = EditorMorphTargetsModel() + unnamed.configure(asset: try loadAsset(writeModel(in: directory, targetNames: nil)), authored: [:]) + #expect(unnamed.targets.isEmpty && unnamed.unnamedTargetCount == 2, "Targets without names cannot be addressed from a scene.") + } + + @Test("Weights clamp, and returning a target to its default removes the override") + func editsWeights() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = EditorMorphTargetsModel() + model.configure(asset: try loadAsset(writeModel(in: directory)), authored: [:]) + let smile = model.targets[0], blink = model.targets[1] + #expect(!model.hasAuthoredWeights) + model.setWeight(5, for: blink) + model.setWeight(.nan, for: smile) + #expect(model.weights == ["blink": 1], "Weights clamp to the slider range and non-finite values are ignored.") + model.setWeight(0.25, for: smile) + #expect(model.weights == ["blink": 1], "A weight equal to the file's default is not an override.") + model.setWeight(0.5, for: smile) + model.setWeight(0, for: blink) + #expect(model.weights == ["smile": 0.5]) + model.reset() + #expect(model.weights.isEmpty && !model.hasAuthoredWeights) + } + + @Test("The payload round trips and ignores non-numbers") + func payloadRoundTrip() { + let weights: [String: Float] = ["smile": 0.5, "blink": 1] + #expect(EditorMorphWeightsPayload.decode(EditorMorphWeightsPayload.encode(weights)) == weights) + #expect(EditorMorphWeightsPayload.decode(nil).isEmpty && EditorMorphWeightsPayload.decode(.string("x")).isEmpty) + #expect(EditorMorphWeightsPayload.decode(.object(["a": .string("no"), "b": .int(1), "c": .double(.nan)])) == ["b": 1]) + } + + /// A scene with one Model 3D entity that points at `Face.gltf` in `directory`, open in a workbench. + private func makeWorkbench(in directory: URL) throws -> (workbench: EditorWorkbenchViewModel, target: EditorSkeletalAnimationTarget, entityID: String, sceneURL: URL) { + _ = try writeModel(in: directory) + let sceneURL = directory.appendingPathComponent("Main.ascn") + var scene = EditorSceneModel.default(projectName: "Morph") + let entity = scene.addEntity(template: .importedModel3D, parentID: scene.rootEntityID) + let index = try #require(scene.entities.firstIndex { $0.id == entity.id }) + scene.entities[index].components[EditorBuiltInComponentType.model3DSource] = [ + "source": .string("@res://Face.gltf"), "animation": .string(""), "autoplay": .bool(false), "repeats": .bool(true), + ] + let content = try scene.encodedYAML() + try content.write(to: sceneURL, atomically: true, encoding: .utf8) + let document = EditorSceneDocument( + id: "Scene", title: "Main", relativePath: "Main.ascn", absolutePath: sceneURL.path, + content: content, lastSavedContent: content, sceneModel: scene, isDirty: false, + loadSummary: .init(entityCount: 0, warnings: []) + ) + let workbench = EditorWorkbenchViewModel(openDocuments: [.scene(document)], activeDocumentID: "Scene") + let target = EditorSkeletalAnimationTarget(documentID: "Scene", entityID: entity.id, source: .init(source: "@res://Face.gltf"), sceneURL: sceneURL) + return (workbench, target, entity.id, sceneURL) + } + + @Test("Applying saves structured scene data and supports undo, redo and clearing") + func sceneTransaction() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let (workbench, target, entityID, sceneURL) = try makeWorkbench(in: directory) + + #expect(workbench.setMorphWeights(["smile": 0.8, "blink": 1], target: target)) + #expect(workbench.saveAllDocuments()) + let reopened = try EditorSceneModel.decode(from: String(contentsOf: sceneURL, encoding: .utf8)) + let saved = try #require(reopened.entities.first { $0.id == entityID }?.components[EditorBuiltInComponentType.model3DSource]) + #expect(EditorMorphWeightsPayload.decode(saved["morphWeights"]) == ["smile": 0.8, "blink": 1]) + let decoded = try #require(EditorComponentRegistry.decode(typeName: EditorBuiltInComponentType.model3DSource, payload: saved) as? Model3DSource) + #expect(decoded.morphWeights == ["smile": 0.8, "blink": 1]) + + #expect(workbench.performDocumentHistory(redo: false)) + let undone = try #require(workbench.activeSceneDocument?.sceneModel?.entities.first { $0.id == entityID }) + #expect(undone.components[EditorBuiltInComponentType.model3DSource]?["morphWeights"] == nil) + #expect(workbench.performDocumentHistory(redo: true)) + let redone = try #require(workbench.activeSceneDocument?.sceneModel?.entities.first { $0.id == entityID }) + #expect(EditorMorphWeightsPayload.decode(redone.components[EditorBuiltInComponentType.model3DSource]?["morphWeights"]) == ["smile": 0.8, "blink": 1]) + + // Clearing removes the key rather than leaving an empty map. + #expect(workbench.setMorphWeights([:], target: target)) + let cleared = try #require(workbench.activeSceneDocument?.sceneModel?.entities.first { $0.id == entityID }) + #expect(cleared.components[EditorBuiltInComponentType.model3DSource]?["morphWeights"] == nil) + let missing = EditorSkeletalAnimationTarget(documentID: "Scene", entityID: "missing", source: .init(source: "x"), sceneURL: sceneURL) + #expect(!workbench.setMorphWeights(["smile": 1], target: missing), "A target that no longer exists changes nothing.") + } + + /// Needs the scene file loader, which the Editor tests of this package cannot run in every environment (the existing + /// `EditorAnimationGraphTests` scene round trip ends the test process the same way where it fails). + @Test("A saved scene opens in the runtime with the authored weights") + func loaderAppliesAuthoredWeights() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let (workbench, target, _, sceneURL) = try makeWorkbench(in: directory) + #expect(workbench.setMorphWeights(["smile": 0.8, "blink": 1], target: target)) + #expect(workbench.saveAllDocuments()) + let reopened = try EditorSceneModel.decode(from: String(contentsOf: sceneURL, encoding: .utf8)) + let world = World() + let result = EditorSceneFileLoader.load(content: try reopened.encodedYAML(), into: world, sourceURL: sceneURL, resourceRootURL: directory) + #expect(result.warnings.isEmpty, Comment(rawValue: result.warnings.joined(separator: "\n"))) + let morphed = try #require(world.getEntities().first { $0.components[MorphWeights3DComponent.self] != nil }) + #expect(morphed.components[MorphWeights3DComponent.self]?.weights == [0.8, 1]) + } + + @Test("The preview applies the weights to the model it shows, including defaults put back") + func previewShowsWeights() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let asset = try loadAsset(writeModel(in: directory)) + let world = World() + _ = asset.instantiate(in: world) + let preview = EditorModelAssetPreviewModel() + preview.asset = asset + preview.bind(to: world) + func weights() throws -> [Float]? { + try #require(world.getEntities().first { $0.components[MorphWeights3DComponent.self] != nil }).components[MorphWeights3DComponent.self]?.weights + } + #expect(try weights() == [0.25, 0], "The file's defaults show before anything is authored.") + preview.morphWeights = ["smile": 0.9, "blink": 0.4] + #expect(try weights() == [0.9, 0.4]) + preview.morphWeights = ["smile": 0.25, "blink": 0] + #expect(try weights() == [0.25, 0], "Giving every target its default weight undoes the preview.") + } + + @Test("The weight list draws one named slider per target") + func listShowsSliders() async throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = EditorMorphTargetsModel() + model.configure(asset: try loadAsset(writeModel(in: directory)), authored: ["smile": 0.5]) + let container = UIContainerView(rootView: EditorMorphWeightList(model: model)) + container.frame = Rect(x: 0, y: 0, width: 480, height: 160) + container.bounds.size = container.frame.size + for _ in 0..<3 { + try await Task.sleep(for: .milliseconds(1)) + container.update(1 / 60) + container.layoutIfNeeded() + } + for name in ["smile", "blink"] { + _ = try container.uiNode(matching: .accessibilityIdentifier("AdaEditor.Morph.Name.\(name)")) + _ = try container.uiNode(matching: .accessibilityIdentifier("AdaEditor.Morph.Slider.\(name)")) + } + } +}