@bornengine/engine 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,1267 @@
1
+ // BloomWatchApp — SwiftUI @main for bloom-backed watchOS games.
2
+ //
3
+ // Compiled by Perry when --features watchos-swift-app is on. Owns the process
4
+ // entry (@main), spawns the game thread calling _perry_user_main, and renders
5
+ // the bloom draw list through a SwiftUI Canvas. Input (Digital Crown + taps)
6
+ // feeds back into bloom via the C hooks in lib.rs.
7
+
8
+ import SwiftUI
9
+ import Foundation
10
+ import SceneKit
11
+ import ImageIO
12
+
13
+ // MARK: - FFI into the Rust side
14
+
15
+ // Perry's --features watchos-swift-app renames C-level `main` → `perry_user_main`
16
+ // (one Mach-O underscore is added by the linker; Swift's @_silgen_name adds
17
+ // another, matching the `__perry_user_main` actually emitted in the object).
18
+ @_silgen_name("_perry_user_main") func _perry_user_main()
19
+
20
+ // Inbound (Swift → Rust) — input and layout
21
+ @_silgen_name("bloom_watchos_crown_delta") func bloom_watchos_crown_delta(_ delta: Double)
22
+ @_silgen_name("bloom_watchos_touch") func bloom_watchos_touch(_ idx: Int64, _ x: Double, _ y: Double, _ active: Int64)
23
+ @_silgen_name("bloom_watchos_set_screen") func bloom_watchos_set_screen(_ w: Double, _ h: Double)
24
+ @_silgen_name("bloom_watchos_set_bundle_path") func bloom_watchos_set_bundle_path(_ path: UnsafePointer<CChar>)
25
+ @_silgen_name("bloom_watchos_set_language") func bloom_watchos_set_language(_ code: Double)
26
+
27
+ // Outbound (Rust → Swift) — draw list snapshot
28
+ @_silgen_name("bloom_watchos_frame_count") func bloom_watchos_frame_count() -> UInt64
29
+ @_silgen_name("bloom_watchos_copy_draw_list") func bloom_watchos_copy_draw_list(_ dst: UnsafeMutablePointer<DrawCmd>, _ max: Int64) -> Int64
30
+ @_silgen_name("bloom_watchos_clear_color") func bloom_watchos_clear_color(_ out: UnsafeMutablePointer<Double>)
31
+ @_silgen_name("bloom_watchos_texture_path") func bloom_watchos_texture_path(_ handle: UInt32) -> UnsafePointer<CChar>?
32
+
33
+ // 3D camera + gate
34
+ @_silgen_name("bloom_watchos_camera_state") func bloom_watchos_camera_state(_ out: UnsafeMutablePointer<Double>)
35
+ @_silgen_name("bloom_watchos_has_3d") func bloom_watchos_has_3d() -> Double
36
+
37
+ // Retained scene graph accessors
38
+ @_silgen_name("bloom_watchos_scene_drain_dirty") func bloom_watchos_scene_drain_dirty(_ dst: UnsafeMutablePointer<SceneNodeInfo>, _ max: Int64) -> Int64
39
+ @_silgen_name("bloom_watchos_scene_drain_destroyed") func bloom_watchos_scene_drain_destroyed(_ dst: UnsafeMutablePointer<UInt32>, _ max: Int64) -> Int64
40
+
41
+ // Mirrors scene.rs DIRTY_* constants.
42
+ let DIRTY_TRANSFORM: UInt32 = 1 << 0
43
+ let DIRTY_MATERIAL: UInt32 = 1 << 1
44
+ let DIRTY_VISIBILITY: UInt32 = 1 << 2
45
+ let DIRTY_PARENT: UInt32 = 1 << 3
46
+ let DIRTY_GEOMETRY: UInt32 = 1 << 4
47
+ let DIRTY_SKIN: UInt32 = 1 << 5
48
+ @_silgen_name("bloom_watchos_scene_copy_lights") func bloom_watchos_scene_copy_lights(_ dst: UnsafeMutablePointer<SceneLight>, _ max: Int64) -> Int64
49
+ @_silgen_name("bloom_watchos_scene_geometry") func bloom_watchos_scene_geometry(_ handle: UInt32, _ out: UnsafeMutablePointer<SceneGeometryPtrs>)
50
+ @_silgen_name("bloom_watchos_scene_skin") func bloom_watchos_scene_skin(_ handle: UInt32, _ out: UnsafeMutablePointer<SceneSkinPtrs>)
51
+ @_silgen_name("bloom_watchos_scene_anim_track_count") func bloom_watchos_scene_anim_track_count(_ handle: UInt32) -> Int64
52
+ @_silgen_name("bloom_watchos_scene_anim_track_info") func bloom_watchos_scene_anim_track_info(_ handle: UInt32, _ idx: Int64, _ out: UnsafeMutablePointer<AnimTrackInfo>)
53
+
54
+ struct AnimTrackInfo {
55
+ var boneHandle: UInt32 = 0
56
+ var path: UInt32 = 0 // 0 = translation, 1 = rotation, 2 = scale
57
+ var keyCount: UInt32 = 0
58
+ var _pad: UInt32 = 0
59
+ var times: UnsafePointer<Float>? = nil
60
+ var values: UnsafePointer<Float>? = nil
61
+ }
62
+
63
+ struct SceneSkinPtrs {
64
+ var jointHandles: UnsafePointer<UInt32>? = nil
65
+ var jointCount: UInt32 = 0
66
+ var inverseBind: UnsafePointer<Float>? = nil
67
+ var inverseBindMatrixCount: UInt32 = 0
68
+ var vertexJoints: UnsafePointer<UInt32>? = nil
69
+ var vertexJointCount: UInt32 = 0
70
+ var vertexWeights: UnsafePointer<Float>? = nil
71
+ var vertexWeightCount: UInt32 = 0
72
+ }
73
+
74
+ // Post-FX state
75
+ @_silgen_name("bloom_watchos_postfx_state") func bloom_watchos_postfx_state(_ out: UnsafeMutablePointer<PostFxState>)
76
+
77
+ struct PostFxState {
78
+ var enabled: UInt32 = 1
79
+ var autoExposure: UInt32 = 0
80
+ var vignetteStrength: Float = 0
81
+ var vignetteSoftness: Float = 0
82
+ var chromaticAberration: Float = 0
83
+ var filmGrain: Float = 0
84
+ var exposure: Float = 1
85
+ var sunStrength: Float = 0
86
+ var sunDecay: Float = 0.85
87
+ var sunR: Float = 1.0
88
+ var sunG: Float = 0.9
89
+ var sunB: Float = 0.7
90
+ }
91
+
92
+ // SceneNodeInfo — must match Rust's #[repr(C)] struct in scene.rs.
93
+ struct SceneNodeInfo {
94
+ var dirtyFlags: UInt32 = 0
95
+ var handle: UInt32 = 0
96
+ var parent: UInt32 = 0
97
+ var visible: UInt32 = 0
98
+ var geometryVersion: UInt32 = 0
99
+
100
+ var color: (Float, Float, Float, Float) = (1, 1, 1, 1)
101
+ var roughness: Float = 0
102
+ var metalness: Float = 0
103
+ var texture: UInt32 = 0
104
+ var hasGeometry: UInt32 = 0
105
+
106
+ var transform: (
107
+ Float, Float, Float, Float,
108
+ Float, Float, Float, Float,
109
+ Float, Float, Float, Float,
110
+ Float, Float, Float, Float
111
+ ) = (1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1)
112
+
113
+ // PBR texture slots (0 = unset).
114
+ var texBaseColor: UInt32 = 0
115
+ var texNormal: UInt32 = 0
116
+ var texMetallicRoughness: UInt32 = 0
117
+ var texEmissive: UInt32 = 0
118
+ var texOcclusion: UInt32 = 0
119
+ // Bumps when bloom sets skin data on this node. 0 = not skinned.
120
+ var skinVersion: UInt32 = 0
121
+ }
122
+
123
+ struct SceneLight {
124
+ var kind: UInt32 = 0
125
+ var _pad: UInt32 = 0
126
+ var posOrDir: (Float, Float, Float) = (0, 0, 0)
127
+ var range: Float = 0
128
+ var color: (Float, Float, Float) = (1, 1, 1)
129
+ var intensity: Float = 0
130
+ }
131
+
132
+ struct SceneGeometryPtrs {
133
+ var positions: UnsafePointer<Float>? = nil
134
+ var positionCount: UInt32 = 0
135
+ var normals: UnsafePointer<Float>? = nil
136
+ var normalCount: UInt32 = 0
137
+ var uvs: UnsafePointer<Float>? = nil
138
+ var uvCount: UInt32 = 0
139
+ var indices: UnsafePointer<UInt32>? = nil
140
+ var indexCount: UInt32 = 0
141
+ }
142
+
143
+ // MARK: - DrawCmd mirror (must match draw_list.rs #[repr(C)])
144
+
145
+ let TEXT_CAP = 256
146
+
147
+ struct DrawCmd {
148
+ var kind: Int32 = 0
149
+ var _pad0: Int32 = 0
150
+ var tex: UInt32 = 0
151
+ var _pad1: UInt32 = 0
152
+ var x: Double = 0, y: Double = 0, w: Double = 0, h: Double = 0
153
+ var srcX: Double = 0, srcY: Double = 0, srcW: Double = 0, srcH: Double = 0
154
+ var ox: Double = 0, oy: Double = 0
155
+ var r: Double = 255, g: Double = 255, b: Double = 255, a: Double = 255
156
+ var rot: Double = 0, size: Double = 0, thickness: Double = 1, _pad2: Double = 0
157
+ // Inline UTF-8 text — 256 bytes expressed as a 32-tuple of UInt64.
158
+ var t00: UInt64 = 0, t01: UInt64 = 0, t02: UInt64 = 0, t03: UInt64 = 0
159
+ var t04: UInt64 = 0, t05: UInt64 = 0, t06: UInt64 = 0, t07: UInt64 = 0
160
+ var t08: UInt64 = 0, t09: UInt64 = 0, t10: UInt64 = 0, t11: UInt64 = 0
161
+ var t12: UInt64 = 0, t13: UInt64 = 0, t14: UInt64 = 0, t15: UInt64 = 0
162
+ var t16: UInt64 = 0, t17: UInt64 = 0, t18: UInt64 = 0, t19: UInt64 = 0
163
+ var t20: UInt64 = 0, t21: UInt64 = 0, t22: UInt64 = 0, t23: UInt64 = 0
164
+ var t24: UInt64 = 0, t25: UInt64 = 0, t26: UInt64 = 0, t27: UInt64 = 0
165
+ var t28: UInt64 = 0, t29: UInt64 = 0, t30: UInt64 = 0, t31: UInt64 = 0
166
+ var textLen: UInt64 = 0
167
+ }
168
+
169
+ // Command kinds — keep in sync with draw_list::kind.
170
+ let K_RECT: Int32 = 1
171
+ let K_RECT_LINES: Int32 = 2
172
+ let K_CIRCLE: Int32 = 3
173
+ let K_CIRCLE_LINES: Int32 = 4
174
+ let K_LINE: Int32 = 5
175
+ let K_TRIANGLE: Int32 = 6
176
+ let K_TEXTURE: Int32 = 7
177
+ let K_TEXTURE_REC: Int32 = 8
178
+ let K_TEXTURE_PRO: Int32 = 9
179
+ let K_TEXT: Int32 = 10
180
+ let K_BEGIN_2D: Int32 = 11
181
+ let K_END_2D: Int32 = 12
182
+
183
+ // 3D primitives
184
+ let K_CUBE: Int32 = 20
185
+ let K_CUBE_WIRES: Int32 = 21
186
+ let K_SPHERE: Int32 = 22
187
+ let K_SPHERE_WIRES: Int32 = 23
188
+ let K_CYLINDER: Int32 = 24
189
+ let K_PLANE: Int32 = 25
190
+ let K_GRID: Int32 = 26
191
+
192
+ /// Decode the inline UTF-8 bytes at the struct's text region into a String.
193
+ /// Takes a pointer into the underlying draw-list buffer so we read the real
194
+ /// bytes, not a stack-copied value (Swift's withUnsafePointer(to:) on a let
195
+ /// field gives a local copy, which truncates anything past 8 bytes).
196
+ func cmdTextString(_ ptr: UnsafePointer<DrawCmd>) -> String {
197
+ let n = Int(min(ptr.pointee.textLen, UInt64(TEXT_CAP)))
198
+ if n == 0 { return "" }
199
+ return UnsafeRawPointer(ptr)
200
+ .advanced(by: MemoryLayout<DrawCmd>.offset(of: \.t00)!)
201
+ .withMemoryRebound(to: UInt8.self, capacity: n) { p in
202
+ let buf = UnsafeBufferPointer(start: p, count: n)
203
+ return String(decoding: buf, as: UTF8.self)
204
+ }
205
+ }
206
+
207
+ // MARK: - Texture cache (handle → CGImage)
208
+
209
+ final class TextureCache {
210
+ static let shared = TextureCache()
211
+ private var cache: [UInt32: CGImage] = [:]
212
+ private let lock = NSLock()
213
+
214
+ func image(for handle: UInt32) -> CGImage? {
215
+ lock.lock()
216
+ defer { lock.unlock() }
217
+ if let img = cache[handle] { return img }
218
+ guard let cpath = bloom_watchos_texture_path(handle) else { return nil }
219
+ let path = String(cString: cpath)
220
+ let url = URL(fileURLWithPath: path)
221
+ // Use CGImageSource so we transparently load PNG, JPEG, and anything
222
+ // else ImageIO understands — the glTF loader embeds JPEGs too.
223
+ guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
224
+ let img = CGImageSourceCreateImageAtIndex(src, 0, nil)
225
+ else { return nil }
226
+ cache[handle] = img
227
+ return img
228
+ }
229
+ }
230
+
231
+ // MARK: - @main
232
+
233
+ @main
234
+ struct BloomWatchApp: App {
235
+ init() {
236
+ // Hand bloom the bundle resource path so bloom_load_texture can
237
+ // resolve relative asset paths. Must happen before the game thread
238
+ // starts loading anything.
239
+ if let res = Bundle.main.resourcePath {
240
+ res.withCString { bloom_watchos_set_bundle_path($0) }
241
+ }
242
+ // Report the user's preferred language before the game thread starts:
243
+ // packed 2-letter primary subtag (c0*256 + c1, lowercased; script
244
+ // subtags dropped, zh-Hans -> "zh") per the bloom_get_language contract.
245
+ let lang = Locale.preferredLanguages.first ?? "en"
246
+ let primary = lang.split(separator: "-").first.map(String.init) ?? "en"
247
+ let scalars = Array(primary.lowercased().unicodeScalars)
248
+ if scalars.count >= 2, scalars[0].isASCII, scalars[1].isASCII {
249
+ bloom_watchos_set_language(Double(scalars[0].value * 256 + scalars[1].value))
250
+ }
251
+ // Spawn the game thread. _perry_user_main blocks in runGame's while
252
+ // loop, so this must not run on the main thread.
253
+ let t = Thread(block: { _perry_user_main() })
254
+ t.name = "bloom-game"
255
+ t.stackSize = 2 * 1024 * 1024
256
+ t.start()
257
+ }
258
+
259
+ var body: some Scene {
260
+ WindowGroup {
261
+ BloomRootView()
262
+ }
263
+ }
264
+ }
265
+
266
+ // MARK: - Root view
267
+
268
+ struct BloomRootView: View {
269
+ // Accumulated crown value (Digital Crown is delta-based in SwiftUI).
270
+ @State private var crown: Double = 0.0
271
+ @State private var lastCrown: Double = 0.0
272
+ // Frame tick — incremented when bloom's frame counter changes. Triggers
273
+ // Canvas redraw.
274
+ @State private var frameTick: UInt64 = 0
275
+ // Reusable snapshot buffer to avoid per-frame allocation. 4096 cmds is
276
+ // generous for a watch-sized game.
277
+ @State private var drawBuf: UnsafeMutableBufferPointer<DrawCmd> = {
278
+ let p = UnsafeMutablePointer<DrawCmd>.allocate(capacity: 4096)
279
+ p.initialize(repeating: DrawCmd(), count: 4096)
280
+ return UnsafeMutableBufferPointer(start: p, count: 4096)
281
+ }()
282
+ @State private var fx: PostFxState = PostFxState()
283
+
284
+ let refresh = Timer.publish(every: 1.0 / 30.0, on: .main, in: .common).autoconnect()
285
+
286
+ var body: some View {
287
+ GeometryReader { geo in
288
+ ZStack {
289
+ // 3D layer — SceneView is always present so the first
290
+ // bloom_begin_mode_3d call can light it up without view
291
+ // reconstruction. The scene is empty (transparent) until a
292
+ // 3D command arrives.
293
+ BloomSceneView(frameTick: frameTick, drawBuf: drawBuf, fx: fx, viewSize: geo.size)
294
+ .ignoresSafeArea()
295
+
296
+ // 2D overlay — Canvas drives drawing from the snapshot and
297
+ // owns input because Canvas is the topmost responder.
298
+ Canvas { ctx, size in
299
+ _ = frameTick
300
+
301
+ // Clear (only when there's no 3D layer — otherwise the
302
+ // SceneView's clear owns the background and this would
303
+ // paint over it).
304
+ if bloom_watchos_has_3d() < 0.5 {
305
+ var cc = [Double](repeating: 0, count: 4)
306
+ cc.withUnsafeMutableBufferPointer { buf in
307
+ bloom_watchos_clear_color(buf.baseAddress!)
308
+ }
309
+ ctx.fill(
310
+ Path(CGRect(origin: .zero, size: size)),
311
+ with: .color(Color(.sRGB,
312
+ red: cc[0] / 255.0,
313
+ green: cc[1] / 255.0,
314
+ blue: cc[2] / 255.0,
315
+ opacity: cc[3] / 255.0))
316
+ )
317
+ }
318
+
319
+ // Pull this frame's commands. 2D draws render here; 3D
320
+ // commands are filtered by BloomSceneView.
321
+ let n = Int(bloom_watchos_copy_draw_list(drawBuf.baseAddress!, 4096))
322
+ // `active` is the context world-space draws use: between a
323
+ // BEGIN_2D / END_2D pair it carries the camera's affine
324
+ // transform (world → screen); otherwise it's the plain
325
+ // screen-space context.
326
+ var active = ctx
327
+ for i in 0..<n {
328
+ let ptr = drawBuf.baseAddress!.advanced(by: i)
329
+ let k = ptr.pointee.kind
330
+ if k >= 20 && k <= 29 { continue } // 3D — handled by SceneView
331
+ if k == K_BEGIN_2D {
332
+ let zoom = ptr.pointee.size
333
+ var t = CGAffineTransform(translationX: ptr.pointee.x, y: ptr.pointee.y)
334
+ t = t.scaledBy(x: zoom, y: zoom)
335
+ t = t.translatedBy(x: -ptr.pointee.w, y: -ptr.pointee.h)
336
+ var w = ctx
337
+ w.transform = t
338
+ active = w
339
+ continue
340
+ }
341
+ if k == K_END_2D {
342
+ active = ctx
343
+ continue
344
+ }
345
+ drawOne(ctx: active, cmdPtr: ptr)
346
+ }
347
+ }
348
+ }
349
+ .onAppear {
350
+ bloom_watchos_set_screen(Double(geo.size.width), Double(geo.size.height))
351
+ }
352
+ .onChange(of: geo.size) { _, new in
353
+ bloom_watchos_set_screen(Double(new.width), Double(new.height))
354
+ }
355
+ .focusable()
356
+ .digitalCrownRotation(
357
+ $crown,
358
+ from: -1_000_000.0, through: 1_000_000.0, by: 0.001,
359
+ sensitivity: .medium,
360
+ isContinuous: true,
361
+ isHapticFeedbackEnabled: false
362
+ )
363
+ .onChange(of: crown) { _, new in
364
+ bloom_watchos_crown_delta(new - lastCrown)
365
+ lastCrown = new
366
+ }
367
+ .onTapGesture(coordinateSpace: .local) { loc in
368
+ bloom_watchos_touch(0, Double(loc.x), Double(loc.y), 1)
369
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
370
+ bloom_watchos_touch(0, Double(loc.x), Double(loc.y), 0)
371
+ }
372
+ }
373
+ .onReceive(refresh) { _ in
374
+ let f = bloom_watchos_frame_count()
375
+ if f != frameTick {
376
+ frameTick = f
377
+ withUnsafeMutablePointer(to: &fx) { bloom_watchos_postfx_state($0) }
378
+ }
379
+ }
380
+ // Post-FX modifiers — applied only when fx.enabled is set. Each
381
+ // effect also checks its own strength before costing anything.
382
+ .brightness(fx.enabled != 0 && fx.autoExposure == 0 ? Double(fx.exposure - 1.0) : 0)
383
+ .overlay {
384
+ if fx.enabled != 0 && fx.vignetteStrength > 0.001 {
385
+ // Vignette stays on pure SwiftUI — cheaper than a shader
386
+ // and identical visually.
387
+ let soft = max(0.0, min(0.95, Double(fx.vignetteSoftness)))
388
+ let str = min(1.0, Double(fx.vignetteStrength))
389
+ Canvas { ctx, size in
390
+ let c = CGPoint(x: size.width * 0.5, y: size.height * 0.5)
391
+ let r = max(size.width, size.height) * 0.75
392
+ let g = Gradient(stops: [
393
+ .init(color: .black.opacity(0), location: soft),
394
+ .init(color: .black.opacity(str), location: 1.0),
395
+ ])
396
+ ctx.fill(
397
+ Path(CGRect(origin: .zero, size: size)),
398
+ with: .radialGradient(g, center: c, startRadius: 0, endRadius: r)
399
+ )
400
+ }
401
+ .allowsHitTesting(false)
402
+ }
403
+ }
404
+ }
405
+ .ignoresSafeArea()
406
+ .background(Color.black)
407
+ }
408
+ }
409
+
410
+ // MARK: - Metal-shader post-FX
411
+ //
412
+ // default.metallib is compiled + bundled by Perry (PerryTS/perry#124) and
413
+ // contains three [[ stitchable ]] shader functions (bloom_chromatic_aberration,
414
+ // bloom_film_grain, bloom_sun_shafts).
415
+ //
416
+ // HOWEVER: SwiftUI's Shader-consumer modifiers —
417
+ // `.colorEffect(Shader)` / `.layerEffect(Shader)` / `ShaderLibrary` —
418
+ // are iOS 17+ / tvOS 17+ / macOS 14+ ONLY. Apple explicitly did not ship
419
+ // them on watchOS, so even though the .metallib sits in the bundle
420
+ // (ShaderLibrary.default would resolve it correctly), there's no way to
421
+ // attach the shaders to a view on the watch today.
422
+ //
423
+ // Paths forward when we revisit:
424
+ // 1. SCNTechnique — SceneKit's pass-based Metal post-process. Works on
425
+ // watchOS and can load from default.metallib, but only applies to the
426
+ // SceneKit layer (not the 2D Canvas overlay). Follow-up ticket.
427
+ // 2. Raw MTLCommandQueue offscreen render + present through a custom
428
+ // SwiftUI view bridge — much more invasive, probably not worth it.
429
+ // 3. Wait for Apple to ship SwiftUI Shader support on watchOS (filed as
430
+ // a Feedback, no ETA).
431
+ //
432
+ // Today vignette + exposure continue to use pure-SwiftUI modifiers above.
433
+
434
+ // MARK: - 3D layer (SceneKit)
435
+
436
+ /// SceneView wrapper that rebuilds the SCNScene each frame from bloom's
437
+ /// immediate-mode 3D draw commands. Retained-mode scene graph (bloom_scene_*)
438
+ /// would use a delta-sync path — deferred until the retained API lands for
439
+ /// watchOS.
440
+ struct BloomSceneView: View {
441
+ let frameTick: UInt64
442
+ let drawBuf: UnsafeMutableBufferPointer<DrawCmd>
443
+ /// Caller passes the current PostFx state so we can keep the technique's
444
+ /// uniforms in sync each frame.
445
+ let fx: PostFxState
446
+ let viewSize: CGSize
447
+
448
+ @State private var scene: SCNScene = makeBaseScene()
449
+ @State private var contentRoot: SCNNode = SCNNode() // immediate-mode per-frame draws
450
+ @State private var retainedRoot: SCNNode = SCNNode() // bloom_scene_* retained nodes
451
+ @State private var lightsRoot: SCNNode = SCNNode() // bloom_add_*_light instances
452
+ @State private var cameraNode: SCNNode = SCNNode()
453
+ /// Built once from default.metallib. Uniforms are pushed per frame in
454
+ /// rebuild(); the technique itself is reused (rebuilding the dict each
455
+ /// frame would churn SceneKit's pipeline cache).
456
+ @State private var fxTechnique: SCNTechnique? = BloomPostFXTechnique.shared
457
+
458
+ /// Cache of SCNNodes for retained scene graph, keyed by bloom handle.
459
+ @State private var retainedNodes: [UInt32: SCNNode] = [:]
460
+ /// Geometry cache keyed by (handle, geometryVersion) so unchanged meshes
461
+ /// aren't rebuilt each frame.
462
+ @State private var retainedGeomVersions: [UInt32: UInt32] = [:]
463
+ /// Same pattern for skinning — we only rebuild SCNSkinner when the
464
+ /// bloom-side skin_version changes, since SCNSkinner carries GPU
465
+ /// resources and rebuilding every frame would churn them.
466
+ @State private var retainedSkinVersions: [UInt32: UInt32] = [:]
467
+
468
+ @State private var nodeBuf: UnsafeMutableBufferPointer<SceneNodeInfo> = {
469
+ let p = UnsafeMutablePointer<SceneNodeInfo>.allocate(capacity: 1024)
470
+ p.initialize(repeating: SceneNodeInfo(), count: 1024)
471
+ return UnsafeMutableBufferPointer(start: p, count: 1024)
472
+ }()
473
+ @State private var lightBuf: UnsafeMutableBufferPointer<SceneLight> = {
474
+ let p = UnsafeMutablePointer<SceneLight>.allocate(capacity: 64)
475
+ p.initialize(repeating: SceneLight(), count: 64)
476
+ return UnsafeMutableBufferPointer(start: p, count: 64)
477
+ }()
478
+
479
+ var body: some View {
480
+ SceneView(
481
+ scene: scene,
482
+ pointOfView: cameraNode,
483
+ options: [.rendersContinuously],
484
+ preferredFramesPerSecond: 30,
485
+ antialiasingMode: .none,
486
+ delegate: nil,
487
+ technique: anyEffectActive ? fxTechnique : nil
488
+ )
489
+ .onAppear {
490
+ if contentRoot.parent == nil {
491
+ scene.rootNode.addChildNode(contentRoot)
492
+ }
493
+ if retainedRoot.parent == nil {
494
+ scene.rootNode.addChildNode(retainedRoot)
495
+ }
496
+ if lightsRoot.parent == nil {
497
+ scene.rootNode.addChildNode(lightsRoot)
498
+ }
499
+ if cameraNode.parent == nil {
500
+ cameraNode.camera = SCNCamera()
501
+ scene.rootNode.addChildNode(cameraNode)
502
+ }
503
+ rebuild()
504
+ }
505
+ .onChange(of: frameTick) { _, _ in rebuild() }
506
+ }
507
+
508
+ /// True when at least one Metal-shader effect is on. Toggles whether
509
+ /// SceneView gets handed the technique at all — when nothing is enabled
510
+ /// SceneKit takes its default no-post-process path.
511
+ private var anyEffectActive: Bool {
512
+ fx.enabled != 0 && (
513
+ fx.chromaticAberration > 0.001 ||
514
+ fx.filmGrain > 0.001 ||
515
+ fx.sunStrength > 0.001
516
+ )
517
+ }
518
+
519
+ private func rebuild() {
520
+ // If the game hasn't opened a 3D section, hide the scene entirely.
521
+ if bloom_watchos_has_3d() < 0.5 {
522
+ contentRoot.isHidden = true
523
+ return
524
+ }
525
+ contentRoot.isHidden = false
526
+
527
+ // Clear previous frame's primitives. Reusing the same contentRoot
528
+ // avoids churn at the SCNScene.rootNode level.
529
+ contentRoot.childNodes.forEach { $0.removeFromParentNode() }
530
+
531
+ // Camera.
532
+ var camS = [Double](repeating: 0, count: 11)
533
+ camS.withUnsafeMutableBufferPointer { buf in bloom_watchos_camera_state(buf.baseAddress!) }
534
+ let px = camS[0], py = camS[1], pz = camS[2]
535
+ let tx = camS[3], ty = camS[4], tz = camS[5]
536
+ let fovy = camS[9], proj = camS[10]
537
+ cameraNode.position = SCNVector3(x: Float(px), y: Float(py), z: Float(pz))
538
+ // Guard against look(at: self.position) which produces a NaN orientation.
539
+ if abs(px - tx) < 1e-6 && abs(py - ty) < 1e-6 && abs(pz - tz) < 1e-6 {
540
+ // No explicit target — point down -Z like a default game camera.
541
+ cameraNode.eulerAngles = SCNVector3(0, 0, 0)
542
+ } else {
543
+ cameraNode.look(at: SCNVector3(x: Float(tx), y: Float(ty), z: Float(tz)))
544
+ }
545
+ if let cam = cameraNode.camera {
546
+ cam.fieldOfView = CGFloat(fovy)
547
+ cam.usesOrthographicProjection = proj < 0.5
548
+ cam.zNear = 0.01
549
+ cam.zFar = 10000
550
+ }
551
+
552
+ // Clear color for SceneKit background.
553
+ var cc = [Double](repeating: 0, count: 4)
554
+ cc.withUnsafeMutableBufferPointer { buf in bloom_watchos_clear_color(buf.baseAddress!) }
555
+ scene.background.contents = UIColor(
556
+ red: CGFloat(cc[0] / 255.0),
557
+ green: CGFloat(cc[1] / 255.0),
558
+ blue: CGFloat(cc[2] / 255.0),
559
+ alpha: CGFloat(cc[3] / 255.0)
560
+ )
561
+
562
+ // Walk the draw list for 3D commands only.
563
+ let n = Int(bloom_watchos_copy_draw_list(drawBuf.baseAddress!, 4096))
564
+ for i in 0..<n {
565
+ let cmd = drawBuf[i]
566
+ if cmd.kind < 20 || cmd.kind > 29 { continue }
567
+ if let node = makeSceneNode(for: cmd) {
568
+ contentRoot.addChildNode(node)
569
+ }
570
+ }
571
+
572
+ // Retained scene graph — delta-sync against the cached handle→SCNNode map.
573
+ syncRetainedNodes()
574
+ syncLights()
575
+ advanceAnimations()
576
+
577
+ // Push current PostFx state into the technique's uniform buffer so
578
+ // chromatic aberration / film grain / sun shafts all sample the
579
+ // up-to-date strengths and animation time.
580
+ if let tech = fxTechnique, anyEffectActive {
581
+ BloomPostFXTechnique.update(tech, fx: fx, viewSize: viewSize)
582
+ }
583
+ }
584
+
585
+ private func syncRetainedNodes() {
586
+ // Step 1: retire destroyed nodes first so their handles don't collide
587
+ // with new-node creations that may reuse slots.
588
+ var destroyed: [UInt32] = Array(repeating: 0, count: 256)
589
+ destroyed.withUnsafeMutableBufferPointer { buf in
590
+ while true {
591
+ let n = Int(bloom_watchos_scene_drain_destroyed(buf.baseAddress!, Int64(buf.count)))
592
+ if n == 0 { break }
593
+ for i in 0..<n {
594
+ let h = buf[i]
595
+ if let node = retainedNodes.removeValue(forKey: h) {
596
+ node.removeFromParentNode()
597
+ }
598
+ retainedGeomVersions.removeValue(forKey: h)
599
+ retainedSkinVersions.removeValue(forKey: h)
600
+ cachedAnimTracks.removeValue(forKey: h)
601
+ cachedAnimDuration.removeValue(forKey: h)
602
+ }
603
+ if n < buf.count { break }
604
+ }
605
+ }
606
+
607
+ // Step 2: drain dirty nodes. Each drain call reports nodes with
608
+ // non-zero dirty_flags and clears them, so we pull until empty. For
609
+ // a static scene this returns 0 on the first call — O(0) sync cost.
610
+ var allDirty: [SceneNodeInfo] = []
611
+ while true {
612
+ let count = Int(bloom_watchos_scene_drain_dirty(nodeBuf.baseAddress!, 1024))
613
+ if count == 0 { break }
614
+ for i in 0..<count { allDirty.append(nodeBuf[i]) }
615
+ if count < 1024 { break }
616
+ }
617
+
618
+ // Step 3: pass 1 — create-or-update nodes, apply dirty fields only.
619
+ for info in allDirty {
620
+ let node: SCNNode
621
+ if let existing = retainedNodes[info.handle] {
622
+ node = existing
623
+ } else {
624
+ node = SCNNode()
625
+ retainedNodes[info.handle] = node
626
+ retainedRoot.addChildNode(node)
627
+ }
628
+
629
+ if info.dirtyFlags & DIRTY_VISIBILITY != 0 {
630
+ node.isHidden = info.visible == 0
631
+ }
632
+ if info.dirtyFlags & DIRTY_TRANSFORM != 0 {
633
+ node.transform = scnMatrix4(from: info.transform)
634
+ }
635
+
636
+ if info.dirtyFlags & DIRTY_GEOMETRY != 0 {
637
+ let cached = retainedGeomVersions[info.handle] ?? .max
638
+ if info.hasGeometry != 0 && cached != info.geometryVersion {
639
+ node.geometry = buildGeometry(handle: info.handle)
640
+ retainedGeomVersions[info.handle] = info.geometryVersion
641
+ }
642
+ }
643
+
644
+ // Skinning: rebuild SCNSkinner when skin_version changes. Bones
645
+ // may not all exist in retainedNodes yet on the first sync — if
646
+ // so, buildSkinner returns nil and we retry on the next tick
647
+ // when all nodes have been created.
648
+ if info.dirtyFlags & DIRTY_SKIN != 0,
649
+ retainedSkinVersions[info.handle] != info.skinVersion {
650
+ if let skinner = buildSkinner(mesh: node, handle: info.handle) {
651
+ node.skinner = skinner
652
+ attachAnimations(handle: info.handle)
653
+ retainedSkinVersions[info.handle] = info.skinVersion
654
+ }
655
+ }
656
+
657
+ if info.dirtyFlags & DIRTY_MATERIAL != 0, let g = node.geometry {
658
+ let m = g.firstMaterial ?? SCNMaterial()
659
+ // PBR needs per-vertex normals. Some glTF exports (e.g. Fox)
660
+ // omit NORMAL entirely — fall back to constant lighting so
661
+ // the mesh still shows its base color / texture instead of
662
+ // rendering pitch black.
663
+ let hasNormals = g.sources(for: .normal).count > 0
664
+ m.lightingModel = hasNormals ? .physicallyBased : .constant
665
+
666
+ // Base color: texture wins over factor when present.
667
+ let baseColorTex = info.texBaseColor != 0 ? info.texBaseColor : info.texture
668
+ if baseColorTex != 0, let img = TextureCache.shared.image(for: baseColorTex) {
669
+ m.diffuse.contents = img
670
+ } else {
671
+ m.diffuse.contents = UIColor(
672
+ red: CGFloat(info.color.0),
673
+ green: CGFloat(info.color.1),
674
+ blue: CGFloat(info.color.2),
675
+ alpha: CGFloat(info.color.3)
676
+ )
677
+ }
678
+
679
+ // Normal map.
680
+ if info.texNormal != 0, let img = TextureCache.shared.image(for: info.texNormal) {
681
+ m.normal.contents = img
682
+ } else {
683
+ m.normal.contents = nil
684
+ }
685
+
686
+ // Metallic-roughness: glTF packs metalness in B, roughness in G
687
+ // into one texture. SceneKit's .metalness + .roughness channels
688
+ // pointing at the same image each sample the correct channel
689
+ // by convention when .textureComponents is set.
690
+ if info.texMetallicRoughness != 0,
691
+ let img = TextureCache.shared.image(for: info.texMetallicRoughness) {
692
+ m.metalness.contents = img
693
+ m.metalness.textureComponents = .blue
694
+ m.roughness.contents = img
695
+ m.roughness.textureComponents = .green
696
+ } else {
697
+ m.metalness.contents = NSNumber(value: info.metalness)
698
+ m.roughness.contents = NSNumber(value: info.roughness)
699
+ }
700
+
701
+ // Emissive.
702
+ if info.texEmissive != 0, let img = TextureCache.shared.image(for: info.texEmissive) {
703
+ m.emission.contents = img
704
+ } else {
705
+ m.emission.contents = nil
706
+ }
707
+
708
+ // AO.
709
+ if info.texOcclusion != 0, let img = TextureCache.shared.image(for: info.texOcclusion) {
710
+ m.ambientOcclusion.contents = img
711
+ } else {
712
+ m.ambientOcclusion.contents = nil
713
+ }
714
+
715
+ g.materials = [m]
716
+ }
717
+ }
718
+
719
+ // Step 4: parent fix-up — only for nodes whose PARENT bit was dirty.
720
+ // Done after the material/geometry pass so both parent and child
721
+ // exist when we reparent, even for newly-spawned subtrees.
722
+ for info in allDirty where info.dirtyFlags & DIRTY_PARENT != 0 {
723
+ guard let node = retainedNodes[info.handle] else { continue }
724
+ let desiredParent: SCNNode = info.parent == 0 ? retainedRoot
725
+ : (retainedNodes[info.parent] ?? retainedRoot)
726
+ if node.parent !== desiredParent {
727
+ node.removeFromParentNode()
728
+ desiredParent.addChildNode(node)
729
+ }
730
+ }
731
+ }
732
+
733
+ private func buildGeometry(handle: UInt32) -> SCNGeometry? {
734
+ var ptrs = SceneGeometryPtrs()
735
+ withUnsafeMutablePointer(to: &ptrs) { bloom_watchos_scene_geometry(handle, $0) }
736
+ guard let pos = ptrs.positions, let idx = ptrs.indices,
737
+ ptrs.positionCount > 0, ptrs.indexCount > 0 else { return nil }
738
+
739
+ let vcount = Int(ptrs.positionCount) / 3
740
+ let posData = Data(bytes: pos, count: Int(ptrs.positionCount) * MemoryLayout<Float>.size)
741
+ let posSrc = SCNGeometrySource(
742
+ data: posData,
743
+ semantic: .vertex,
744
+ vectorCount: vcount,
745
+ usesFloatComponents: true,
746
+ componentsPerVector: 3,
747
+ bytesPerComponent: MemoryLayout<Float>.size,
748
+ dataOffset: 0,
749
+ dataStride: 3 * MemoryLayout<Float>.size
750
+ )
751
+
752
+ var sources: [SCNGeometrySource] = [posSrc]
753
+ if let nrm = ptrs.normals, ptrs.normalCount >= ptrs.positionCount {
754
+ let nrmData = Data(bytes: nrm, count: Int(ptrs.normalCount) * MemoryLayout<Float>.size)
755
+ sources.append(SCNGeometrySource(
756
+ data: nrmData,
757
+ semantic: .normal,
758
+ vectorCount: vcount,
759
+ usesFloatComponents: true,
760
+ componentsPerVector: 3,
761
+ bytesPerComponent: MemoryLayout<Float>.size,
762
+ dataOffset: 0,
763
+ dataStride: 3 * MemoryLayout<Float>.size
764
+ ))
765
+ }
766
+ if let uv = ptrs.uvs, ptrs.uvCount >= UInt32(vcount * 2) {
767
+ let uvData = Data(bytes: uv, count: Int(ptrs.uvCount) * MemoryLayout<Float>.size)
768
+ sources.append(SCNGeometrySource(
769
+ data: uvData,
770
+ semantic: .texcoord,
771
+ vectorCount: vcount,
772
+ usesFloatComponents: true,
773
+ componentsPerVector: 2,
774
+ bytesPerComponent: MemoryLayout<Float>.size,
775
+ dataOffset: 0,
776
+ dataStride: 2 * MemoryLayout<Float>.size
777
+ ))
778
+ }
779
+
780
+ let idxData = Data(bytes: idx, count: Int(ptrs.indexCount) * MemoryLayout<UInt32>.size)
781
+ let element = SCNGeometryElement(
782
+ data: idxData,
783
+ primitiveType: .triangles,
784
+ primitiveCount: Int(ptrs.indexCount) / 3,
785
+ bytesPerIndex: MemoryLayout<UInt32>.size
786
+ )
787
+
788
+ return SCNGeometry(sources: sources, elements: [element])
789
+ }
790
+
791
+ private func buildSkinner(mesh: SCNNode, handle: UInt32) -> SCNSkinner? {
792
+ var ptrs = SceneSkinPtrs()
793
+ withUnsafeMutablePointer(to: &ptrs) { bloom_watchos_scene_skin(handle, $0) }
794
+ guard let jointsPtr = ptrs.jointHandles,
795
+ let ibmPtr = ptrs.inverseBind,
796
+ let vjPtr = ptrs.vertexJoints,
797
+ let vwPtr = ptrs.vertexWeights,
798
+ ptrs.jointCount > 0, ptrs.vertexJointCount > 0,
799
+ let geom = mesh.geometry
800
+ else { return nil }
801
+
802
+ // Resolve bone SCNNodes from bloom handles. Skip if any is missing
803
+ // — skinner without all bones would crash on draw.
804
+ var bones: [SCNNode] = []
805
+ bones.reserveCapacity(Int(ptrs.jointCount))
806
+ for i in 0..<Int(ptrs.jointCount) {
807
+ let h = jointsPtr[i]
808
+ guard let node = retainedNodes[h] else { return nil }
809
+ bones.append(node)
810
+ }
811
+
812
+ // Inverse-bind matrices wrapped as NSValue(SCNMatrix4).
813
+ var ibmValues: [NSValue] = []
814
+ ibmValues.reserveCapacity(Int(ptrs.inverseBindMatrixCount))
815
+ for i in 0..<Int(ptrs.inverseBindMatrixCount) {
816
+ let base = i * 16
817
+ let m = SCNMatrix4(
818
+ m11: ibmPtr[base+0], m12: ibmPtr[base+1], m13: ibmPtr[base+2], m14: ibmPtr[base+3],
819
+ m21: ibmPtr[base+4], m22: ibmPtr[base+5], m23: ibmPtr[base+6], m24: ibmPtr[base+7],
820
+ m31: ibmPtr[base+8], m32: ibmPtr[base+9], m33: ibmPtr[base+10], m34: ibmPtr[base+11],
821
+ m41: ibmPtr[base+12], m42: ibmPtr[base+13], m43: ibmPtr[base+14], m44: ibmPtr[base+15]
822
+ )
823
+ ibmValues.append(NSValue(scnMatrix4: m))
824
+ }
825
+
826
+ // Bone weights + indices as SCNGeometrySources. 4 per vertex.
827
+ let vertexCount = Int(ptrs.vertexWeightCount) / 4
828
+ let weightsData = Data(bytes: vwPtr, count: Int(ptrs.vertexWeightCount) * MemoryLayout<Float>.size)
829
+ let weightsSrc = SCNGeometrySource(
830
+ data: weightsData, semantic: .boneWeights, vectorCount: vertexCount,
831
+ usesFloatComponents: true, componentsPerVector: 4,
832
+ bytesPerComponent: MemoryLayout<Float>.size, dataOffset: 0,
833
+ dataStride: 4 * MemoryLayout<Float>.size
834
+ )
835
+ let indicesData = Data(bytes: vjPtr, count: Int(ptrs.vertexJointCount) * MemoryLayout<UInt32>.size)
836
+ let indicesSrc = SCNGeometrySource(
837
+ data: indicesData, semantic: .boneIndices, vectorCount: vertexCount,
838
+ usesFloatComponents: false, componentsPerVector: 4,
839
+ bytesPerComponent: MemoryLayout<UInt32>.size, dataOffset: 0,
840
+ dataStride: 4 * MemoryLayout<UInt32>.size
841
+ )
842
+
843
+ return SCNSkinner(
844
+ baseGeometry: geom,
845
+ bones: bones,
846
+ boneInverseBindTransforms: ibmValues,
847
+ boneWeights: weightsSrc,
848
+ boneIndices: indicesSrc
849
+ )
850
+ }
851
+
852
+ /// Cache of per-skinned-mesh animation tracks. CAKeyframeAnimation
853
+ /// isn't available on watchOS, so we drive the skeleton by hand each
854
+ /// frame via setBoneTransforms — this cache avoids rebuilding from
855
+ /// FFI pointers every tick.
856
+ private struct CachedTrack {
857
+ let boneHandle: UInt32
858
+ let path: UInt32 // 0 translation, 1 rotation, 2 scale
859
+ let times: [Float]
860
+ let values: [Float]
861
+ }
862
+ @State private var cachedAnimTracks: [UInt32: [CachedTrack]] = [:]
863
+ @State private var cachedAnimDuration: [UInt32: Float] = [:]
864
+
865
+ /// Cache the animation tracks for a skinned mesh. Called once alongside
866
+ /// skinner build; the per-frame loop (advanceAnimations) then drives
867
+ /// playback against these cached arrays.
868
+ private func attachAnimations(handle: UInt32) {
869
+ let n = bloom_watchos_scene_anim_track_count(handle)
870
+ if n <= 0 {
871
+ cachedAnimTracks.removeValue(forKey: handle)
872
+ cachedAnimDuration.removeValue(forKey: handle)
873
+ return
874
+ }
875
+
876
+ var tracks: [CachedTrack] = []
877
+ tracks.reserveCapacity(Int(n))
878
+ var duration: Float = 0
879
+ for i in 0..<n {
880
+ var info = AnimTrackInfo()
881
+ withUnsafeMutablePointer(to: &info) { bloom_watchos_scene_anim_track_info(handle, i, $0) }
882
+ let keyCount = Int(info.keyCount)
883
+ guard keyCount > 0, let tp = info.times, let vp = info.values else { continue }
884
+ let times = Array(UnsafeBufferPointer(start: tp, count: keyCount))
885
+ let valueWidth = info.path == 1 ? 4 : 3
886
+ let values = Array(UnsafeBufferPointer(start: vp, count: keyCount * valueWidth))
887
+ if let last = times.last, last > duration { duration = last }
888
+ tracks.append(CachedTrack(boneHandle: info.boneHandle, path: info.path, times: times, values: values))
889
+ }
890
+ cachedAnimTracks[handle] = tracks
891
+ cachedAnimDuration[handle] = duration
892
+ }
893
+
894
+ /// Advance every cached animation by scene-time, interpolating each
895
+ /// track between the bracketing keyframes and writing the result into
896
+ /// the corresponding bone's SCNNode.
897
+ private func advanceAnimations() {
898
+ // CACurrentMediaTime isn't on watchOS — use a Foundation clock.
899
+ // Relative-since-process-start is enough since we modulo by duration.
900
+ let now = Float(ProcessInfo.processInfo.systemUptime)
901
+ for (meshHandle, tracks) in cachedAnimTracks {
902
+ guard let dur = cachedAnimDuration[meshHandle], dur > 0 else { continue }
903
+ // Wrap to duration — looping playback.
904
+ var t = now.truncatingRemainder(dividingBy: dur)
905
+ if t < 0 { t += dur }
906
+ for track in tracks {
907
+ guard let bone = retainedNodes[track.boneHandle] else { continue }
908
+ applyTrackAt(t: t, track: track, bone: bone)
909
+ }
910
+ }
911
+ }
912
+
913
+ private func applyTrackAt(t: Float, track: CachedTrack, bone: SCNNode) {
914
+ let count = track.times.count
915
+ if count == 0 { return }
916
+
917
+ // Find the keyframe segment [i, i+1] where times[i] <= t < times[i+1].
918
+ // Clamp at the ends.
919
+ var i = 0
920
+ if t <= track.times[0] {
921
+ i = 0
922
+ } else if t >= track.times[count - 1] {
923
+ i = count - 1
924
+ } else {
925
+ // Linear scan — fine for <100 keys per track (typical).
926
+ while i + 1 < count && track.times[i + 1] < t { i += 1 }
927
+ }
928
+
929
+ let width = track.path == 1 ? 4 : 3
930
+ let base0 = i * width
931
+ let base1 = (i + 1 < count ? i + 1 : i) * width
932
+
933
+ let t0 = track.times[i]
934
+ let t1 = i + 1 < count ? track.times[i + 1] : t0
935
+ let alpha: Float = t1 > t0 ? (t - t0) / (t1 - t0) : 0
936
+
937
+ switch track.path {
938
+ case 0: // translation
939
+ let x = lerp(track.values[base0 + 0], track.values[base1 + 0], alpha)
940
+ let y = lerp(track.values[base0 + 1], track.values[base1 + 1], alpha)
941
+ let z = lerp(track.values[base0 + 2], track.values[base1 + 2], alpha)
942
+ bone.position = SCNVector3(x, y, z)
943
+ case 2: // scale
944
+ let x = lerp(track.values[base0 + 0], track.values[base1 + 0], alpha)
945
+ let y = lerp(track.values[base0 + 1], track.values[base1 + 1], alpha)
946
+ let z = lerp(track.values[base0 + 2], track.values[base1 + 2], alpha)
947
+ bone.scale = SCNVector3(x, y, z)
948
+ default: // rotation (quaternion xyzw) — slerp
949
+ let q0 = (track.values[base0 + 0], track.values[base0 + 1], track.values[base0 + 2], track.values[base0 + 3])
950
+ let q1 = (track.values[base1 + 0], track.values[base1 + 1], track.values[base1 + 2], track.values[base1 + 3])
951
+ let q = slerp(q0, q1, alpha)
952
+ bone.orientation = SCNQuaternion(q.0, q.1, q.2, q.3)
953
+ }
954
+ }
955
+
956
+ private func syncLights() {
957
+ let n = Int(bloom_watchos_scene_copy_lights(lightBuf.baseAddress!, 64))
958
+ // Simpler than delta sync for lights — there are few of them and
959
+ // bloom's intended use is setting them once on scene init. Rebuild
960
+ // each frame.
961
+ lightsRoot.childNodes.forEach { $0.removeFromParentNode() }
962
+ for i in 0..<n {
963
+ let l = lightBuf[i]
964
+ let node = SCNNode()
965
+ let light = SCNLight()
966
+ light.color = UIColor(
967
+ red: CGFloat(l.color.0),
968
+ green: CGFloat(l.color.1),
969
+ blue: CGFloat(l.color.2),
970
+ alpha: 1.0
971
+ )
972
+ light.intensity = CGFloat(l.intensity * 1000.0) // bloom's 0..1 → SceneKit lumens
973
+ if l.kind == 1 {
974
+ light.type = .directional
975
+ // Point light forward along `pos_or_dir`.
976
+ node.look(at: SCNVector3(
977
+ x: node.position.x + l.posOrDir.0,
978
+ y: node.position.y + l.posOrDir.1,
979
+ z: node.position.z + l.posOrDir.2
980
+ ))
981
+ } else if l.kind == 2 {
982
+ light.type = .omni
983
+ light.attenuationEndDistance = CGFloat(l.range)
984
+ node.position = SCNVector3(x: l.posOrDir.0, y: l.posOrDir.1, z: l.posOrDir.2)
985
+ }
986
+ node.light = light
987
+ lightsRoot.addChildNode(node)
988
+ }
989
+ }
990
+ }
991
+
992
+ /// Convert a column-major flat 16-float transform (bloom's layout) into an
993
+ /// SCNMatrix4. SCNMatrix4 is column-major too, so it's a direct mapping.
994
+ private func scnMatrix4(from m: (
995
+ Float, Float, Float, Float,
996
+ Float, Float, Float, Float,
997
+ Float, Float, Float, Float,
998
+ Float, Float, Float, Float
999
+ )) -> SCNMatrix4 {
1000
+ return SCNMatrix4(
1001
+ m11: m.0, m12: m.1, m13: m.2, m14: m.3,
1002
+ m21: m.4, m22: m.5, m23: m.6, m24: m.7,
1003
+ m31: m.8, m32: m.9, m33: m.10, m34: m.11,
1004
+ m41: m.12, m42: m.13, m43: m.14, m44: m.15
1005
+ )
1006
+ }
1007
+
1008
+ private func lerp(_ a: Float, _ b: Float, _ t: Float) -> Float { a + (b - a) * t }
1009
+
1010
+ /// Spherical-linear interpolation between two quaternions (xyzw). Handles
1011
+ /// the double-cover sign flip so the short arc wins.
1012
+ private func slerp(_ q0: (Float, Float, Float, Float),
1013
+ _ q1: (Float, Float, Float, Float),
1014
+ _ t: Float) -> (Float, Float, Float, Float) {
1015
+ var x1 = q1.0, y1 = q1.1, z1 = q1.2, w1 = q1.3
1016
+ let dot = q0.0 * x1 + q0.1 * y1 + q0.2 * z1 + q0.3 * w1
1017
+ var d = dot
1018
+ if d < 0 {
1019
+ x1 = -x1; y1 = -y1; z1 = -z1; w1 = -w1
1020
+ d = -d
1021
+ }
1022
+ if d > 0.9995 {
1023
+ // Nearly collinear — plain lerp avoids sin(0)/0.
1024
+ let x = lerp(q0.0, x1, t); let y = lerp(q0.1, y1, t)
1025
+ let z = lerp(q0.2, z1, t); let w = lerp(q0.3, w1, t)
1026
+ let inv = 1.0 / (x * x + y * y + z * z + w * w).squareRoot()
1027
+ return (x * inv, y * inv, z * inv, w * inv)
1028
+ }
1029
+ let theta = acos(d)
1030
+ let sinTheta = sin(theta)
1031
+ let a = sin((1 - t) * theta) / sinTheta
1032
+ let b = sin(t * theta) / sinTheta
1033
+ return (q0.0 * a + x1 * b, q0.1 * a + y1 * b, q0.2 * a + z1 * b, q0.3 * a + w1 * b)
1034
+ }
1035
+
1036
+ // MARK: - Post-FX SCNTechnique
1037
+ //
1038
+ // SwiftUI's `.colorEffect(Shader)` / `.layerEffect(Shader)` aren't on
1039
+ // watchOS, but SCNTechnique IS — partially. Verified working:
1040
+ // - SCNTechnique(dictionary:) → builds the pass graph
1041
+ // - Attaching technique to SCNView via SwiftUI SceneView's `technique:`
1042
+ // parameter → applies the post-process pass
1043
+ // - Metal shaders compiled into default.metallib via Perry's
1044
+ // metal_sources pipeline → load and execute correctly (proven by a
1045
+ // debug shader that returns a constant red)
1046
+ // - COLOR-semantic input/output binding → routes the scene's main color
1047
+ // attachment in and back out
1048
+ //
1049
+ // What does NOT work on watchOS:
1050
+ // - Per-draw uniform push. SCNTechnique's
1051
+ // `handleBindingOfSymbol:usingBlock:` takes an `SCNRenderer` parameter,
1052
+ // and `SCNRenderer.h` simply isn't in the watchOS SDK — the method is
1053
+ // uncallable from Swift. SceneKit's `setObject(NSData,forKeyedSubscript:)`
1054
+ // route, which works for SCNProgram per-material shaders, doesn't
1055
+ // deliver values to SCNTechnique-bound Metal `[[buffer(N)]]` slots.
1056
+ //
1057
+ // So today the technique can run a fixed-strength post-process (hardcode
1058
+ // values in the shader, recompile to change them), but the
1059
+ // bloom_set_chromatic_aberration / _film_grain / _sun_shafts dynamic-knob
1060
+ // API has no path to drive its strengths on watchOS.
1061
+ //
1062
+ // Tracking: Bloom-Engine#16 stays open with this finding; Apple Feedback
1063
+ // filed for SCNRenderer / handleBinding parity on watchOS. See the
1064
+ // bloom_postfx.metal shader for the math — it's ready to wire up the
1065
+ // moment Apple ships either SCNRenderer or `.colorEffect(Shader)` on the
1066
+ // watch.
1067
+ enum BloomPostFXTechnique {
1068
+
1069
+ /// Built once on first access. Currently no-op on watchOS — the
1070
+ /// technique would attach and run, but with all uniform strengths
1071
+ /// stuck at zero (no per-draw push API), every effect short-circuits
1072
+ /// to pass-through. Returning nil signals BloomSceneView to skip the
1073
+ /// technique entirely until uniforms can be wired.
1074
+ static let shared: SCNTechnique? = nil
1075
+
1076
+ static func update(_ technique: SCNTechnique, fx: PostFxState, viewSize: CGSize) {
1077
+ // No-op until uniform binding lands. See file-level comment.
1078
+ _ = technique; _ = fx; _ = viewSize
1079
+ }
1080
+ }
1081
+
1082
+ private func makeBaseScene() -> SCNScene {
1083
+ let s = SCNScene()
1084
+ // Minimal default lighting so objects aren't pitch black before the game
1085
+ // adds its own lights.
1086
+ let ambient = SCNNode()
1087
+ ambient.light = SCNLight()
1088
+ ambient.light?.type = .ambient
1089
+ ambient.light?.intensity = 300
1090
+ s.rootNode.addChildNode(ambient)
1091
+
1092
+ let dir = SCNNode()
1093
+ dir.light = SCNLight()
1094
+ dir.light?.type = .directional
1095
+ dir.light?.intensity = 800
1096
+ dir.eulerAngles = SCNVector3(-Float.pi / 3, Float.pi / 6, 0)
1097
+ s.rootNode.addChildNode(dir)
1098
+ return s
1099
+ }
1100
+
1101
+ private func makeSceneNode(for cmd: DrawCmd) -> SCNNode? {
1102
+ let pos = SCNVector3(Float(cmd.x), Float(cmd.y), Float(cmd.srcX))
1103
+ let color = UIColor(
1104
+ red: CGFloat(cmd.r / 255.0),
1105
+ green: CGFloat(cmd.g / 255.0),
1106
+ blue: CGFloat(cmd.b / 255.0),
1107
+ alpha: CGFloat(cmd.a / 255.0)
1108
+ )
1109
+
1110
+ let geo: SCNGeometry?
1111
+ switch cmd.kind {
1112
+ case K_CUBE, K_CUBE_WIRES:
1113
+ geo = SCNBox(width: CGFloat(cmd.w), height: CGFloat(cmd.h),
1114
+ length: CGFloat(cmd.size), chamferRadius: 0)
1115
+ case K_SPHERE, K_SPHERE_WIRES:
1116
+ geo = SCNSphere(radius: CGFloat(cmd.w))
1117
+ case K_CYLINDER:
1118
+ geo = SCNCylinder(radius: CGFloat(cmd.w), height: CGFloat(cmd.h))
1119
+ case K_PLANE:
1120
+ geo = SCNPlane(width: CGFloat(cmd.w), height: CGFloat(cmd.h))
1121
+ case K_GRID:
1122
+ return makeGridNode(slices: Int(cmd.w), spacing: Float(cmd.h),
1123
+ color: color)
1124
+ default:
1125
+ return nil
1126
+ }
1127
+ guard let g = geo else { return nil }
1128
+ let m = SCNMaterial()
1129
+ m.lightingModel = .physicallyBased
1130
+ m.diffuse.contents = color
1131
+ m.roughness.contents = 0.6
1132
+ m.metalness.contents = 0.0
1133
+ if cmd.kind == K_CUBE_WIRES || cmd.kind == K_SPHERE_WIRES {
1134
+ m.fillMode = .lines
1135
+ }
1136
+ g.materials = [m]
1137
+
1138
+ let node = SCNNode(geometry: g)
1139
+ node.position = pos
1140
+ return node
1141
+ }
1142
+
1143
+ /// Build a grid as line segments — SceneKit doesn't have a built-in grid,
1144
+ /// so we construct two sets of parallel SCNGeometry lines.
1145
+ private func makeGridNode(slices: Int, spacing: Float, color: UIColor) -> SCNNode {
1146
+ let root = SCNNode()
1147
+ let s = slices < 1 ? 10 : slices
1148
+ let half = Float(s) * spacing / 2.0
1149
+
1150
+ var verts: [SCNVector3] = []
1151
+ var indices: [Int32] = []
1152
+ var idx: Int32 = 0
1153
+ for i in 0...s {
1154
+ let p = -half + Float(i) * spacing
1155
+ verts.append(SCNVector3(p, 0, -half))
1156
+ verts.append(SCNVector3(p, 0, half))
1157
+ indices.append(idx); indices.append(idx + 1); idx += 2
1158
+ verts.append(SCNVector3(-half, 0, p))
1159
+ verts.append(SCNVector3( half, 0, p))
1160
+ indices.append(idx); indices.append(idx + 1); idx += 2
1161
+ }
1162
+ let src = SCNGeometrySource(vertices: verts)
1163
+ let data = Data(bytes: indices, count: indices.count * MemoryLayout<Int32>.size)
1164
+ let el = SCNGeometryElement(data: data, primitiveType: .line,
1165
+ primitiveCount: indices.count / 2,
1166
+ bytesPerIndex: MemoryLayout<Int32>.size)
1167
+ let geom = SCNGeometry(sources: [src], elements: [el])
1168
+ let mat = SCNMaterial()
1169
+ mat.diffuse.contents = color
1170
+ mat.isDoubleSided = true
1171
+ mat.lightingModel = .constant
1172
+ geom.materials = [mat]
1173
+ root.geometry = geom
1174
+ return root
1175
+ }
1176
+
1177
+ // MARK: - Single-command draw
1178
+
1179
+ private func drawOne(ctx: GraphicsContext, cmdPtr: UnsafePointer<DrawCmd>) {
1180
+ let cmd = cmdPtr.pointee
1181
+ let color = Color(.sRGB,
1182
+ red: cmd.r / 255.0,
1183
+ green: cmd.g / 255.0,
1184
+ blue: cmd.b / 255.0,
1185
+ opacity: cmd.a / 255.0)
1186
+
1187
+ switch cmd.kind {
1188
+ case K_RECT:
1189
+ ctx.fill(Path(CGRect(x: cmd.x, y: cmd.y, width: cmd.w, height: cmd.h)),
1190
+ with: .color(color))
1191
+
1192
+ case K_RECT_LINES:
1193
+ ctx.stroke(Path(CGRect(x: cmd.x, y: cmd.y, width: cmd.w, height: cmd.h)),
1194
+ with: .color(color),
1195
+ lineWidth: cmd.thickness)
1196
+
1197
+ case K_CIRCLE:
1198
+ ctx.fill(Path(ellipseIn: CGRect(x: cmd.x - cmd.w, y: cmd.y - cmd.w,
1199
+ width: cmd.w * 2, height: cmd.w * 2)),
1200
+ with: .color(color))
1201
+
1202
+ case K_CIRCLE_LINES:
1203
+ ctx.stroke(Path(ellipseIn: CGRect(x: cmd.x - cmd.w, y: cmd.y - cmd.w,
1204
+ width: cmd.w * 2, height: cmd.w * 2)),
1205
+ with: .color(color),
1206
+ lineWidth: cmd.thickness)
1207
+
1208
+ case K_LINE:
1209
+ var path = Path()
1210
+ path.move(to: CGPoint(x: cmd.x, y: cmd.y))
1211
+ path.addLine(to: CGPoint(x: cmd.srcX, y: cmd.srcY))
1212
+ ctx.stroke(path, with: .color(color), lineWidth: cmd.thickness)
1213
+
1214
+ case K_TRIANGLE:
1215
+ var path = Path()
1216
+ path.move(to: CGPoint(x: cmd.x, y: cmd.y))
1217
+ path.addLine(to: CGPoint(x: cmd.srcX, y: cmd.srcY))
1218
+ path.addLine(to: CGPoint(x: cmd.srcW, y: cmd.srcH))
1219
+ path.closeSubpath()
1220
+ ctx.fill(path, with: .color(color))
1221
+
1222
+ case K_TEXTURE:
1223
+ drawTexture(ctx: ctx, cmd: cmd, dstRect: CGRect(x: cmd.x, y: cmd.y, width: cmd.w, height: cmd.h), srcRect: nil)
1224
+
1225
+ case K_TEXTURE_REC:
1226
+ let src = CGRect(x: cmd.srcX, y: cmd.srcY, width: cmd.srcW, height: cmd.srcH)
1227
+ let dst = CGRect(x: cmd.x, y: cmd.y, width: cmd.w, height: cmd.h)
1228
+ drawTexture(ctx: ctx, cmd: cmd, dstRect: dst, srcRect: src)
1229
+
1230
+ case K_TEXTURE_PRO:
1231
+ let src = CGRect(x: cmd.srcX, y: cmd.srcY, width: cmd.srcW, height: cmd.srcH)
1232
+ let dst = CGRect(x: cmd.x - cmd.ox, y: cmd.y - cmd.oy, width: cmd.w, height: cmd.h)
1233
+ if cmd.rot != 0 {
1234
+ var nested = ctx
1235
+ nested.translateBy(x: cmd.x, y: cmd.y)
1236
+ nested.rotate(by: .degrees(cmd.rot))
1237
+ nested.translateBy(x: -cmd.ox, y: -cmd.oy)
1238
+ drawTexture(ctx: nested, cmd: cmd,
1239
+ dstRect: CGRect(x: 0, y: 0, width: cmd.w, height: cmd.h),
1240
+ srcRect: src)
1241
+ } else {
1242
+ drawTexture(ctx: ctx, cmd: cmd, dstRect: dst, srcRect: src)
1243
+ }
1244
+
1245
+ case K_TEXT:
1246
+ let s = cmdTextString(cmdPtr)
1247
+ if s.isEmpty { return }
1248
+ let font = Font.system(size: cmd.size > 0 ? cmd.size : 14)
1249
+ let text = Text(s).font(font).foregroundColor(color)
1250
+ ctx.draw(text, at: CGPoint(x: cmd.x, y: cmd.y), anchor: .topLeading)
1251
+
1252
+ default:
1253
+ break
1254
+ }
1255
+ }
1256
+
1257
+ private func drawTexture(ctx: GraphicsContext, cmd: DrawCmd, dstRect: CGRect, srcRect: CGRect?) {
1258
+ guard let full = TextureCache.shared.image(for: cmd.tex) else { return }
1259
+ let cg: CGImage
1260
+ if let s = srcRect, let cropped = full.cropping(to: s) {
1261
+ cg = cropped
1262
+ } else {
1263
+ cg = full
1264
+ }
1265
+ let img = Image(cg, scale: 1.0, label: Text(""))
1266
+ ctx.draw(img, in: dstRect)
1267
+ }