feat(av-live-body): scene Metal + pose OSC

Phase 1 fusion : AV-Live-Body absorbe la couche Metal viz et
ecoute les data pose via OSC.

Metal scene (10 viz modes :  storm, tunnel, plasma, kaleido,
voronoi, metaballs, starfield, bars, hands3d, openpos) :
- Resources/scene.metal copie depuis data_only_viz, compile au
  runtime via MTLLibrary.makeLibrary(source).
- SceneRenderer.swift : MTKViewDelegate qui rebuild
  SceneUniforms (20 floats, miroir du struct Metal) et drive
  bg_pipeline (full-screen triangle).
- BodyView : nouveau MTKView entre la cam preview et l'ARView,
  zPosition intermediaire, alpha pour laisser passer la cam.
- RenderSettings : showScene + vizMode (0..9), picker 10
  boutons numerotes dans SettingsPanel + libelle du mode actif
  affiche dans la row 'Scene Metal (<name>)'.

Pose OSC :
- PoseOSCListener.swift : UDP listener :57126, parser OSC
  minimal (i, f, s args), @MainActor dispatch des Published.
  Stocke un PoseFrame par pid (center, head, wrists, sho_span,
  yaw, pitch) avec GC 2 s.
- data_only_viz/pose_bridge.py : 2e SimpleUDPClient broadcast
  vers 127.0.0.1:57126 (try/except OSError pour silencer si
  AVLiveBody pas la). Throttle 30 Hz partage.

Phase 2 (futur) : rendu skeleton entities RealityKit (spheres +
cylindres) consommant PoseOSCListener.persons.

Package.swift : ajout Resources/scene.metal en .copy.
This commit is contained in:
L'électron rare
2026-05-13 21:22:57 +02:00
parent 7f0ac97a21
commit 667f63c385
9 changed files with 958 additions and 1 deletions
+11
View File
@@ -39,6 +39,9 @@ class PoseSoundBridge:
def __init__(self, sclang_host: str = "127.0.0.1",
sclang_port: int = 57121, throttle_hz: float = 30.0) -> None:
self._client = SimpleUDPClient(sclang_host, sclang_port)
# Broadcast secondaire vers AV-Live-Body (Swift) pour overlay
# skeleton dans la fenetre RealityKit. Silent si pas connecte.
self._avbody = SimpleUDPClient("127.0.0.1", 57126)
self._period = 1.0 / max(1.0, throttle_hz)
self._last_t = 0.0
@@ -52,6 +55,8 @@ class PoseSoundBridge:
n = len(persons_body)
try:
self._client.send_message("/pose/count", [int(n)])
try: self._avbody.send_message("/pose/count", [int(n)])
except OSError: pass
except OSError:
return # SC pas la, on continue silencieusement
if n == 0:
@@ -72,6 +77,8 @@ class PoseSoundBridge:
cx = sum(p[0] for p in visible) / len(visible)
cy = sum(p[1] for p in visible) / len(visible)
cli.send_message("/pose/center", [pid, float(cx), float(cy)])
try: self._avbody.send_message("/pose/center", [pid, float(cx), float(cy)])
except OSError: pass
# Nez (visage) — important pour piloter une voix
if len(body) > NOSE and body[NOSE].c > 0.3:
@@ -95,6 +102,8 @@ class PoseSoundBridge:
and body[LEFT_SHO].c > 0.3 and body[RIGHT_SHO].c > 0.3):
dx = abs(body[LEFT_SHO].x - body[RIGHT_SHO].x)
cli.send_message("/pose/sho_span", [pid, float(dx)])
try: self._avbody.send_message("/pose/sho_span", [pid, float(dx)])
except OSError: pass
# Envergure poignets (mouvement expressif)
if (len(body) > RIGHT_WRIST
@@ -102,3 +111,5 @@ class PoseSoundBridge:
span = ((body[LEFT_WRIST].x - body[RIGHT_WRIST].x) ** 2
+ (body[LEFT_WRIST].y - body[RIGHT_WRIST].y) ** 2) ** 0.5
cli.send_message("/pose/limb_span", [pid, float(span)])
try: self._avbody.send_message("/pose/limb_span", [pid, float(span)])
except OSError: pass
+1
View File
@@ -10,6 +10,7 @@ let package = Package(
path: "Sources/AVLiveBody",
resources: [
.copy("Resources/smplx_faces.bin"),
.copy("Resources/scene.metal"),
],
swiftSettings: [
.swiftLanguageMode(.v5),
@@ -27,11 +27,15 @@ extension Notification.Name {
struct ContentView: View {
@StateObject private var renderer = MeshRenderer()
@StateObject private var settings = RenderSettings()
@StateObject private var poseListener = PoseOSCListener()
var body: some View {
ZStack(alignment: .topTrailing) {
BodyView(renderer: renderer, settings: settings)
.onAppear { renderer.startOSCServer() }
.onAppear {
renderer.startOSCServer()
poseListener.start()
}
.onReceive(NotificationCenter.default.publisher(
for: .toggleSettings)) { _ in
settings.showPanel.toggle()
@@ -1,4 +1,5 @@
import AVFoundation
import MetalKit
import RealityKit
import SwiftUI
@@ -28,6 +29,24 @@ struct BodyView: NSViewRepresentable {
preview.isHidden = !settings.showCamera
container.layer?.addSublayer(preview)
// 1b. MTKView des scenes Metal (storm/tunnel/openpos/...) en
// couche intermediaire entre la cam et l'ARView. Transparent
// par-dessus la cam (alpha blending via clearColor).
let scene = SceneRenderer.make()
let mtkView = MTKView(frame: container.bounds,
device: scene?.uniforms != nil
? MTLCreateSystemDefaultDevice() : nil)
mtkView.delegate = scene
mtkView.colorPixelFormat = .bgra8Unorm
mtkView.framebufferOnly = false
mtkView.layer?.isOpaque = false
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0,
alpha: 0)
mtkView.preferredFramesPerSecond = 60
mtkView.autoresizingMask = [.width, .height]
mtkView.isHidden = !settings.showScene
container.addSubview(mtkView)
// 2. ARView transparent isOpaque false sinon le compositeur
// OS reecrit l'alpha
let arView = ARView(frame: container.bounds)
@@ -77,6 +96,8 @@ struct BodyView: NSViewRepresentable {
context.coordinator.bodyAnchor = bodyAnchor
context.coordinator.arView = arView
context.coordinator.cameraEntity = camEntity
context.coordinator.sceneRenderer = scene
context.coordinator.mtkView = mtkView
context.coordinator.keyLight = key
context.coordinator.fillLight = fill
context.coordinator.rimLight = rim
@@ -91,6 +112,8 @@ struct BodyView: NSViewRepresentable {
// Apply live settings
c.previewLayer?.opacity = Float(settings.camOpacity)
c.previewLayer?.isHidden = !settings.showCamera
c.mtkView?.isHidden = !settings.showScene
c.sceneRenderer?.uniforms.viz_mode = Float(settings.vizMode)
c.container?.layer?.backgroundColor = NSColor(
white: CGFloat(settings.bgBrightness), alpha: 1.0).cgColor
c.cameraEntity?.camera.fieldOfViewInDegrees =
@@ -119,6 +142,8 @@ struct BodyView: NSViewRepresentable {
var bodyAnchor: AnchorEntity?
var arView: ARView?
var cameraEntity: PerspectiveCamera?
var sceneRenderer: SceneRenderer?
var mtkView: MTKView?
var keyLight: DirectionalLight?
var fillLight: DirectionalLight?
var rimLight: DirectionalLight?
@@ -0,0 +1,198 @@
import Foundation
import Network
import simd
/// Listener UDP sur :57126 qui parse les messages OSC envoyes par
/// data_only_viz/pose_bridge.py et publie l'etat des personnes
/// detectees pour le rendu skeleton dans BodyView. La classe n'est
/// pas @MainActor pour pouvoir etre callbackee par Network.framework
/// depuis sa queue globale ; on hop sur MainActor pour les Published.
final class PoseOSCListener: ObservableObject {
/// Position (x, y normalises 0..1) + confidence par personne.
/// On garde uniquement les routes interessantes pour overlay 3D :
/// /pose/center, /pose/wrist, /pose/head, /pose/sho_span,
/// /pose/torso_yaw, /pose/body_pitch.
struct PoseFrame: Equatable {
var center: SIMD2<Float> = .zero
var head: SIMD2<Float> = .zero
var wristL: SIMD2<Float> = .zero
var wristR: SIMD2<Float> = .zero
var shoSpan: Float = 0
var torsoYaw: Float = 0
var bodyPitch: Float = 0
var seenAt: TimeInterval = 0
}
@Published var persons: [Int: PoseFrame] = [:]
@Published var count: Int = 0
private var listener: NWListener?
private func updatePublished(_ block: @escaping () -> Void) {
DispatchQueue.main.async(execute: block)
}
func start(port: UInt16 = 57126) {
do {
let params = NWParameters.udp
params.allowLocalEndpointReuse = true
let l = try NWListener(using: params,
on: NWEndpoint.Port(rawValue: port)!)
l.newConnectionHandler = { [weak self] conn in
conn.start(queue: .global(qos: .userInitiated))
self?.receive(on: conn)
}
l.start(queue: .global())
self.listener = l
NSLog("PoseOSCListener: udp :%d up", port)
} catch {
NSLog("PoseOSCListener: bind :%d failed : %@",
Int(port), String(describing: error))
}
}
private func receive(on conn: NWConnection) {
conn.receiveMessage { [weak self] data, _, _, error in
if let data = data, !data.isEmpty {
self?.handle(packet: data)
}
if error == nil {
self?.receive(on: conn)
}
}
}
private func handle(packet: Data) {
guard let (address, types, payload) = parseOSCHeader(packet) else {
return
}
let args = parseOSCArgs(types: types, data: payload)
updatePublished { [weak self] in
self?.apply(address: address, args: args)
}
}
private func apply(address: String, args: [Any]) {
switch address {
case "/pose/count":
if let n = args.first as? Int32 { count = Int(n) }
case "/pose/center":
guard args.count >= 3,
let pid = args[0] as? Int32,
let cx = args[1] as? Float,
let cy = args[2] as? Float else { return }
var p = persons[Int(pid)] ?? PoseFrame()
p.center = SIMD2(cx, cy)
p.seenAt = CFAbsoluteTimeGetCurrent()
persons[Int(pid)] = p
case "/pose/head":
guard args.count >= 4,
let pid = args[0] as? Int32,
let x = args[1] as? Float,
let y = args[2] as? Float else { return }
var p = persons[Int(pid)] ?? PoseFrame()
p.head = SIMD2(x, y)
persons[Int(pid)] = p
case "/pose/wrist":
guard args.count >= 4,
let pid = args[0] as? Int32,
let side = args[1] as? String,
let x = args[2] as? Float,
let y = args[3] as? Float else { return }
var p = persons[Int(pid)] ?? PoseFrame()
if side == "l" {
p.wristL = SIMD2(x, y)
} else {
p.wristR = SIMD2(x, y)
}
persons[Int(pid)] = p
case "/pose/sho_span":
guard args.count >= 2,
let pid = args[0] as? Int32,
let dx = args[1] as? Float else { return }
var p = persons[Int(pid)] ?? PoseFrame()
p.shoSpan = dx
persons[Int(pid)] = p
case "/pose/torso_yaw":
guard args.count >= 2,
let pid = args[0] as? Int32,
let v = args[1] as? Float else { return }
var p = persons[Int(pid)] ?? PoseFrame()
p.torsoYaw = v
persons[Int(pid)] = p
case "/pose/body_pitch":
guard args.count >= 2,
let pid = args[0] as? Int32,
let v = args[1] as? Float else { return }
var p = persons[Int(pid)] ?? PoseFrame()
p.bodyPitch = v
persons[Int(pid)] = p
default:
break
}
// Garbage-collect persons non vues depuis > 2 s
let now = CFAbsoluteTimeGetCurrent()
persons = persons.filter { $0.value.seenAt == 0
|| now - $0.value.seenAt < 2.0 }
}
// MARK: - Minimal OSC parser
private func align4(_ n: Int) -> Int { (n + 3) & ~3 }
private func parseOSCHeader(_ data: Data
) -> (String, String, Data)? {
// Address jusqu'au \0
guard let endAddr = data.firstIndex(of: 0) else { return nil }
let address = String(data: data[..<endAddr], encoding: .ascii) ?? ""
let addrEnd = align4(endAddr - data.startIndex + 1)
guard addrEnd < data.count else { return nil }
let rest = data[(data.startIndex + addrEnd)...]
guard let comma = rest.first, comma == UInt8(ascii: ","),
let endTypes = rest.firstIndex(of: 0) else { return nil }
let typesData = rest[rest.startIndex.advanced(by: 1)..<endTypes]
let types = String(data: typesData, encoding: .ascii) ?? ""
let typesEnd = align4(endTypes - rest.startIndex + 1)
guard typesEnd <= rest.count else { return nil }
let payload = rest[rest.startIndex.advanced(by: typesEnd)...]
return (address, types, Data(payload))
}
private func parseOSCArgs(types: String, data: Data) -> [Any] {
var args: [Any] = []
var offset = 0
for t in types {
switch t {
case "i":
guard offset + 4 <= data.count else { return args }
let v = data.withUnsafeBytes {
$0.loadUnaligned(fromByteOffset: offset, as: Int32.self)
}.bigEndian
args.append(v)
offset += 4
case "f":
guard offset + 4 <= data.count else { return args }
let raw = data.withUnsafeBytes {
$0.loadUnaligned(fromByteOffset: offset, as: UInt32.self)
}.bigEndian
args.append(Float(bitPattern: raw))
offset += 4
case "s":
let start = offset
while offset < data.count
&& data[data.startIndex.advanced(by: offset)] != 0 {
offset += 1
}
let lo = data.startIndex.advanced(by: start)
let hi = data.startIndex.advanced(by: offset)
let slice = data[lo..<hi]
let s = String(data: slice, encoding: .ascii) ?? ""
args.append(s)
offset = align4(offset + 1)
default:
return args
}
}
return args
}
}
@@ -13,6 +13,15 @@ final class RenderSettings: ObservableObject {
// Background
@Published var bgBrightness: Double = 0.08
// Metal scene background (10 viz modes : storm/tunnel/.../openpos)
@Published var showScene: Bool = true
@Published var vizMode: Int = 0 // 0..9
var vizModeName: String {
let names = ["storm", "tunnel", "plasma", "kaleido", "voronoi",
"metaballs", "starfield", "bars", "hands3d", "openpos"]
return names.indices.contains(vizMode) ? names[vizMode] : "?"
}
// Mesh visibility / style
@Published var showMesh: Bool = true
@Published var showWireframe: Bool = false
@@ -0,0 +1,570 @@
// scene.metal — fond reactif aux flux data_feeds + skeleton overlay.
//
// 9 modes visuels style demoscene 2023+ (raymarching SDF, fractales,
// parallax, palette IQ). Reactivite open-data via SceneUniforms.
// 0 storm fbm tissu palette Kp/Bz + lightning flash
// 1 tunnel raymarched tube avec anneaux translucents (wind, RMS)
// 2 plasma volumetric noise palette IQ (Kp, social_rate)
// 3 kaleido fractal KIFS 6-fold rotation 3D (flare, time)
// 4 voronoi cellular 3D crystal sphere (lightning, RMS)
// 5 metaballs raymarched SDF metaballs colored shading (RMS, beat)
// 6 starfield galaxy spiral parallax + god rays (wind, kp)
// 7 bars 3D pillars en perspective avec depth fog (RMS+social)
// 8 hands3d raymarching mandelbox-like + hands camera control
#include <metal_stdlib>
using namespace metal;
struct SceneUniforms {
float time;
float rms;
float kp_norm;
float netz_dev;
float lightning_flash;
float flare;
float wind_norm;
float bz_norm;
float social_rate;
float pose_alive;
float pose_count;
float width;
float height;
float viz_mode;
float hand_l_x;
float hand_l_y;
float hand_r_x;
float hand_r_y;
float _pad0;
float _pad1;
};
struct VsOut {
float4 position [[position]];
float2 uv;
};
vertex VsOut bg_vertex(uint vid [[vertex_id]]) {
float2 p = float2((vid << 1) & 2, vid & 2);
VsOut o;
o.position = float4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = p;
return o;
}
// ===== Helpers ====================================================
float hash21(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float hash31(float3 p) {
p = fract(p * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float noise2(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float a = hash21(i);
float b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1));
float d = hash21(i + float2(1, 1));
float2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(float2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; ++i) { v += a * noise2(p); p *= 2.13; a *= 0.5; }
return v;
}
// Palette cosinusoidale IQ : 3 tons doux
float3 palIQ(float t, float3 a, float3 b, float3 c, float3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
// Rotations
float3 rotY(float3 p, float a) {
float c = cos(a), s = sin(a);
return float3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z);
}
float3 rotX(float3 p, float a) {
float c = cos(a), s = sin(a);
return float3(p.x, c * p.y - s * p.z, s * p.y + c * p.z);
}
float3 rotZ(float3 p, float a) {
float c = cos(a), s = sin(a);
return float3(c * p.x - s * p.y, s * p.x + c * p.y, p.z);
}
// SDF primitives
float sdSphere(float3 p, float r) { return length(p) - r; }
float sdBox(float3 p, float3 b) {
float3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float sdTorus(float3 p, float2 t) {
float2 q = float2(length(p.xz) - t.x, p.y);
return length(q) - t.y;
}
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float vignette(float2 p) {
return 1.0 - smoothstep(0.6, 1.5, length(p));
}
// ===== Modes =======================================================
// ---- 0 storm : tissu fbm reactif + bloom-fake ----
float3 mode_storm(float2 p, constant SceneUniforms& U) {
float storm = saturate(U.kp_norm * 1.0 + max(-U.bz_norm, 0.0) * 0.5);
float speed = 0.08 + U.wind_norm * 1.5;
float zoom = 1.8 - U.rms * 1.2;
float n = fbm(p * zoom + float2(U.time * speed, U.time * speed * 0.7));
n = pow(n, 1.2 - U.rms * 0.5);
float netz = sin(U.time * 50.0 + U.netz_dev * 800.0) * 0.06;
float3 base = palIQ(n + storm * 0.5,
float3(0.10, 0.05, 0.20),
float3(0.40, 0.30, 0.55),
float3(1.0, 1.0, 1.0),
float3(0.0, 0.33, 0.67));
float bloom = smoothstep(0.7, 1.0, n);
return base * (n * 1.4 + 0.3) + netz + U.rms * 1.2
+ bloom * 0.7
+ float3(1.0, 0.55, 0.1) * U.flare * 1.4
+ float3(U.lightning_flash * 0.7);
}
// ---- 1 tunnel : raymarched cylindrical tube avec anneaux ----
float3 mode_tunnel(float2 p, constant SceneUniforms& U) {
// Pseudo-3D tunnel: r/theta + scrolling z
float r = length(p);
float a = atan2(p.y, p.x);
float z = U.time * (1.5 + U.wind_norm * 8.0 + U.rms * 4.0);
// Repeat depth
float d = 1.0 / max(r, 0.04) + z;
// anneaux + spirale
float ring = sin(d * 4.0) * 0.5 + 0.5;
float spiral = sin(a * (8.0 + U.kp_norm * 6.0) + d * 0.6);
float v = ring * (0.4 + 0.6 * spiral);
// Iris central
v *= smoothstep(0.05, 0.20, r);
float3 base = palIQ(d * 0.06 + U.time * 0.05,
float3(0.15, 0.05, 0.35),
float3(0.55, 0.25, 0.35),
float3(1.0, 1.0, 0.8),
float3(0.0, 0.10, 0.20));
float3 col = base * v;
// Chromatic aberration fake : sample displaced
float chrom = U.lightning_flash * 0.15;
col.r *= 1.0 + chrom; col.b *= 1.0 - chrom;
return col + float3(1.0, 0.7, 0.3) * U.flare * 1.5
+ float3(U.lightning_flash * 0.6);
}
// ---- 2 plasma : volumetric noise palette IQ ----
float3 mode_plasma(float2 p, constant SceneUniforms& U) {
float t = U.time * (0.5 + U.rms * 1.5);
// 3 octaves de sin/cos en composition
float v = sin(p.x * 4.0 + t)
+ sin(p.y * 5.0 - t * 1.2)
+ sin((p.x + p.y) * 3.5 + t * 0.7)
+ sin(length(p) * (8.0 + U.kp_norm * 4.0) - t * 1.8);
v = v * 0.25 + 0.5;
// Fake volumetric "depth" : repeat layers
float layer2 = sin(p.x * 2.0 - t * 0.5) * sin(p.y * 2.5 + t * 0.7);
v = mix(v, v * 0.5 + 0.5 * (layer2 + 1.0) * 0.5, 0.35);
float3 col = palIQ(v,
float3(0.5),
float3(0.5),
float3(1.0, 1.0, 1.0),
float3(0.0, 0.33, 0.67));
col *= 0.8 + U.kp_norm * 0.7 + U.social_rate * 0.5;
return col + float3(0.6, 0.3, 1.0) * U.lightning_flash * 0.5;
}
// ---- 3 kaleido : KIFS fractal 6-fold avec rot 3D fake ----
float3 mode_kaleido(float2 p, constant SceneUniforms& U) {
float ang = U.time * 0.15 + U.flare * 2.0;
float c = cos(ang), s = sin(ang);
p = float2(c * p.x - s * p.y, s * p.x + c * p.y);
float r = length(p);
float a = atan2(p.y, p.x);
float seg = 6.28318 / 6.0;
a = abs(fmod(a + seg * 0.5, seg) - seg * 0.5);
float2 q = float2(cos(a), sin(a)) * r;
// Iteration KIFS-like
float scale = 1.0;
for (int i = 0; i < 4; ++i) {
q = abs(q) - 0.35;
if (q.y > q.x) q = q.yx;
q *= 1.5; scale *= 1.5;
}
float v = length(q) / scale;
float n = fbm(q * 3.0 + U.time * 0.2);
float3 col = palIQ(v + n * 0.3,
float3(0.20, 0.10, 0.30),
float3(0.55, 0.40, 0.50),
float3(1.0, 1.0, 0.5),
float3(0.0, 0.25, 0.50));
col = col * (1.0 - exp(-v * 6.0));
return col * (0.8 + U.rms * 1.0)
+ float3(1.0, 0.6, 0.2) * U.flare * 1.2;
}
// ---- 4 voronoi : 3D crystalline cellular ----
float3 mode_voronoi(float2 p, constant SceneUniforms& U) {
// 3D voronoi : on echantillonne dans une grille 3D animee
float t = U.time * (0.4 + U.rms * 1.0);
float3 P = float3(p * 3.5, t);
float3 ip = floor(P);
float3 fp = fract(P);
float d1 = 10.0, d2 = 10.0;
for (int z = -1; z <= 1; ++z)
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) {
float3 g = float3(float(x), float(y), float(z));
float3 o = float3(hash31(ip + g + 13.0),
hash31(ip + g + 71.0),
hash31(ip + g + 47.0));
o = 0.5 + 0.5 * sin(t + 6.28 * o);
float3 dv = g + o - fp;
float d = dot(dv, dv);
if (d < d1) { d2 = d1; d1 = d; }
else if (d < d2) { d2 = d; }
}
d1 = sqrt(d1); d2 = sqrt(d2);
float edge = smoothstep(0.0, 0.04, d2 - d1); // walls between cells
float face = smoothstep(0.0, 0.6, d1);
float3 base = palIQ(d1,
float3(0.05, 0.08, 0.20),
float3(0.45, 0.35, 0.55),
float3(1.0, 1.0, 0.6),
float3(0.2, 0.3, 0.0));
return base * (1.0 - face) + float3(1.0) * (1.0 - edge) * 0.5
+ U.lightning_flash * 0.8;
}
// ---- 5 metaballs : raymarched SDF ----
float metaballs_dist(float3 p, constant SceneUniforms& U) {
float t = U.time * 0.7;
float d = 100.0;
for (int k = 0; k < 5; ++k) {
float fk = float(k);
float3 c = float3(
sin(t * (0.6 + 0.13 * fk) + fk * 1.7) * 1.2,
cos(t * (0.5 + 0.11 * fk) + fk * 2.1) * 1.0,
sin(t * (0.4 + 0.09 * fk) + fk * 3.0) * 0.8
);
float radius = 0.45 + 0.15 * U.rms + 0.05 * sin(t + fk);
d = smin(d, sdSphere(p - c, radius), 0.45);
}
return d;
}
float3 mode_metaballs(float2 p, constant SceneUniforms& U) {
float3 ro = float3(0, 0, -3.5);
float3 rd = normalize(float3(p, 1.5));
float t = 0.0;
float glow = 0.0;
int i;
for (i = 0; i < 64; ++i) {
float3 pos = ro + rd * t;
float d = metaballs_dist(pos, U);
if (d < 0.01) break;
glow += 0.02 / (1.0 + d * d * 4.0);
t += d * 0.9;
if (t > 8.0) break;
}
float3 col = float3(0);
if (t < 8.0) {
float3 pos = ro + rd * t;
// normal via gradient
float2 e = float2(0.001, 0);
float3 n = normalize(float3(
metaballs_dist(pos + e.xyy, U) - metaballs_dist(pos - e.xyy, U),
metaballs_dist(pos + e.yxy, U) - metaballs_dist(pos - e.yxy, U),
metaballs_dist(pos + e.yyx, U) - metaballs_dist(pos - e.yyx, U)));
float3 lightDir = normalize(float3(0.6, 0.8, -0.5));
float lambert = max(0.0, dot(n, lightDir));
float fres = pow(1.0 - max(0.0, dot(n, -rd)), 2.0);
col = palIQ(pos.x * 0.3 + pos.y * 0.2 + U.time * 0.1,
float3(0.2, 0.0, 0.3),
float3(0.5, 0.5, 0.4),
float3(1.0),
float3(0.0, 0.33, 0.67)) * lambert;
col += float3(0.3, 0.7, 1.0) * fres * (0.7 + U.kp_norm);
}
col += float3(0.2, 0.6, 1.0) * glow * 1.5;
return col + U.lightning_flash * 0.6;
}
// ---- 6 starfield : galaxy spiral + parallax ----
float3 mode_starfield(float2 p, constant SceneUniforms& U) {
float warp = U.time * (1.5 + U.wind_norm * 6.0);
// 3 layers of stars at different speeds
float3 col = float3(0);
for (int L = 0; L < 3; ++L) {
float speed = (1.0 + float(L) * 0.5);
float scale = 6.0 + float(L) * 4.0;
for (int k = 0; k < 50; ++k) {
float fk = float(k + L * 50);
float r0 = hash21(float2(fk, 7.0 + float(L)));
float a0 = hash21(float2(fk, 17.0 + float(L))) * 6.28;
// Spirale galactique
float angle = a0 + r0 * 4.0;
float dist = fract(r0 + warp * 0.04 * speed) * 1.6;
float2 q = float2(cos(angle + dist * 1.5),
sin(angle + dist * 1.5)) * dist;
float d = length(p - q);
float bright = smoothstep(0.012 / speed, 0.0, d);
col += float3(0.5 + r0 * 0.5, 0.7 - r0 * 0.3, 1.0) * bright
* (1.4 - dist) * (1.0 / speed);
}
}
// God rays subtils depuis le centre
float ang = atan2(p.y, p.x);
float rays = 0.5 + 0.5 * sin(ang * 8.0 + U.time);
col += float3(0.3, 0.4, 0.7) * rays * (1.0 - length(p)) * 0.15
* (0.5 + U.kp_norm);
return col + U.flare * float3(1.0, 0.5, 0.2) * 0.4;
}
// ---- 7 bars : 3D pillars en perspective ----
float3 mode_bars(float2 p, constant SceneUniforms& U) {
// Pseudo-3D : barres "horizontales" qui s'eloignent
int nbars = 24;
float t = U.time * 0.4;
float3 col = float3(0);
// Sky gradient
float3 sky = mix(float3(0.05, 0.0, 0.15), float3(0.25, 0.1, 0.35),
p.y * 0.5 + 0.5);
col = sky;
for (int i = 0; i < nbars; ++i) {
float fi = float(i) / float(nbars);
// Position en profondeur (z = 0 proche, 1 loin)
float z = fract(fi + t * (0.15 + U.rms * 0.3));
float perspective = 1.0 / (z + 0.1);
float y_base = -0.6 + z * 1.2; // ligne d'horizon
// Hauteur barre depend du bin "i" via hash + RMS
float h0 = hash21(float2(float(i), 0.0));
float h = sin(t * (0.5 + h0 * 4.0) + float(i)) * 0.5 + 0.5;
h = h * (0.3 + U.rms * 1.5 + U.social_rate * 0.4);
h = clamp(h, 0.02, 0.85);
float bar_top = y_base + h * perspective * 0.3;
// Largeur = 1 / nbars perspective
float bx = (fi - 0.5) * perspective * 1.5;
float bw = 0.5 / float(nbars) * perspective;
if (abs(p.x - bx) < bw &&
p.y > y_base && p.y < bar_top) {
float3 c = palIQ(fi,
float3(0.5), float3(0.5),
float3(1.0, 1.0, 0.5),
float3(0.0, 0.33, 0.67));
// Fog selon z
c *= 1.0 - z * 0.6;
col = mix(col, c, 1.0 - z * 0.3);
}
}
// Grille du sol scanline
float floor_y = -0.6;
if (p.y < floor_y) {
float depth = (floor_y - p.y) * 4.0;
float grid = step(0.95, fract(p.x * 8.0 / max(depth, 0.1)));
grid += step(0.95, fract(depth * 4.0 + t));
col += float3(0.2, 0.3, 0.6) * grid * 0.4;
}
return col + U.flare * float3(1.0, 0.5, 0.2) * 0.3;
}
// ---- 8 hands3d : voyage 3D pilote par les mains ----
float map_hands(float3 p, constant SceneUniforms& U) {
float3 q = fmod(p + 2.0, 4.0) - 2.0;
float d = length(q) - 0.6;
float pulse = 0.8 + U.rms * 0.6;
d = min(d, length(p) - pulse);
d += sin(p.x * 2.0 + U.time) * 0.15 * U.kp_norm;
return d;
}
float3 mode_hands3d(float2 p, constant SceneUniforms& U) {
float hl_active = (abs(U.hand_l_x) + abs(U.hand_l_y)) > 0.01 ? 1.0 : 0.0;
float hr_active = (abs(U.hand_r_x) + abs(U.hand_r_y)) > 0.01 ? 1.0 : 0.0;
float3 cam_pos = float3(
U.hand_l_x * 5.0,
U.hand_l_y * 3.0,
-U.time * (1.5 + U.hand_l_y * 4.0 * hl_active)
);
float yaw = U.hand_r_x * 1.2 * hr_active;
float pitch = -U.hand_r_y * 0.8 * hr_active;
float3 rd = normalize(float3(p.x, p.y, 1.5));
rd = rotX(rd, pitch);
rd = rotY(rd, yaw);
float t = 0.0, glow = 0.0;
for (int i = 0; i < 64; ++i) {
float3 pos = cam_pos + rd * t;
float d = map_hands(pos, U);
if (d < 0.005) break;
glow += 0.02 / (1.0 + d * d * 8.0);
t += d * 0.85;
if (t > 30.0) break;
}
float3 col = float3(0);
if (t < 30.0) {
float3 pos = cam_pos + rd * t;
float fog = 1.0 - saturate(t / 30.0);
col = float3(
0.5 + 0.5 * sin(pos.x * 0.4 + U.time),
0.5 + 0.5 * sin(pos.y * 0.5 + U.time * 1.3),
0.5 + 0.5 * sin(pos.z * 0.3 + U.time * 0.7)
) * fog;
}
col += float3(0.2, 0.6, 1.0) * glow * 1.5;
col += float3(1.0, 0.5, 0.0) * U.flare * 0.8;
return col;
}
// ---- 9 openpos : fond minimal radial pour faire ressortir le squelette ----
// Le rendu des joints + bones se fait par le skel_pipeline rendu PAR-DESSUS
// (cf renderer.py). On laisse juste un degrade radial sombre pour le contraste.
float3 mode_openpos(float2 p, constant SceneUniforms& U) {
float r = length(p);
// Centre legerement plus clair, bords sombres. Touche de couleur
// chaude au centre selon rms pour reagir a la musique.
float3 inner = float3(0.05, 0.05, 0.10) + float3(0.30, 0.12, 0.18) * U.rms;
float3 outer = float3(0.01, 0.01, 0.02);
float3 col = mix(inner, outer, smoothstep(0.0, 1.4, r));
// Grille de points discrete pour donner une ref de profondeur
float2 g = fmod(p * 12.0, 2.0) - 1.0;
float dot_grid = exp(-dot(g, g) * 6.0) * 0.04;
col += float3(dot_grid);
// Pulsation legere sur le kick / drop
col *= 1.0 + U.rms * 0.4;
return col;
}
// ===== Fragment dispatcher =========================================
fragment float4 bg_fragment(VsOut in [[stage_in]],
constant SceneUniforms& U [[buffer(0)]]) {
float2 uv = in.uv;
float2 p = uv * 2.0 - 1.0;
p.x *= U.width / U.height;
int mode = int(U.viz_mode + 0.5);
float3 color;
if (mode == 1) color = mode_tunnel(p, U);
else if (mode == 2) color = mode_plasma(p, U);
else if (mode == 3) color = mode_kaleido(p, U);
else if (mode == 4) color = mode_voronoi(p, U);
else if (mode == 5) color = mode_metaballs(p, U);
else if (mode == 6) color = mode_starfield(p, U);
else if (mode == 7) color = mode_bars(p, U);
else if (mode == 8) color = mode_hands3d(p, U);
else if (mode == 9) color = mode_openpos(p, U);
else color = mode_storm(p, U);
// Flash global + vignette
color += float3(U.lightning_flash * 1.2);
color *= vignette(p);
// Tone mapping doux (Reinhard)
color = color / (1.0 + color);
// Gamma
color = pow(color, float3(0.85));
// Alpha pour transparence quand pose active (webcam visible dessous)
// Overlay vidéo : translucide même sans pose (la webcam doit rester
// visible en fond). Pose active = encore plus translucide.
float alpha = mix(0.55, 0.25, U.pose_alive);
alpha = max(alpha, U.lightning_flash * 0.8);
alpha = max(alpha, U.flare * 0.6);
return float4(color, alpha);
}
// ===== Skeleton overlay ============================================
struct SkelIn {
float3 pos [[attribute(0)]]; // x,y dans NDC, z profondeur (~ -0.5..+0.5)
float conf [[attribute(1)]];
float pid [[attribute(2)]]; // person_id (0..9)
};
struct SkelOut {
float4 position [[position]];
float conf;
float pid;
float depth;
};
// Projection perspective douce : eloigne avec z, garde NDC en x,y
vertex SkelOut skel_vertex(SkelIn in [[stage_in]],
constant SceneUniforms& U [[buffer(1)]]) {
SkelOut o;
float z = clamp(in.pos.z, -1.0, 1.0);
// Perspective : plus z augmente, plus le point est loin → scale < 1
// RMS pulse fait respirer la profondeur
float pulse = 1.0 + U.rms * 0.25;
float persp = 1.0 / (1.0 + z * 0.8);
float2 xy = in.pos.xy * persp * pulse;
o.position = float4(xy, 0.0, 1.0);
o.conf = in.conf;
o.pid = in.pid;
o.depth = z;
return o;
}
// Palette 6 couleurs par personne (turquoise, magenta, jaune, ambre, lilas, vert)
constant float3 PERSON_COLORS[6] = {
float3(0.0, 1.0, 0.85), // 0 turquoise
float3(1.0, 0.3, 0.7), // 1 magenta
float3(1.0, 0.9, 0.2), // 2 jaune
float3(1.0, 0.55, 0.1), // 3 ambre
float3(0.7, 0.5, 1.0), // 4 lilas
float3(0.4, 1.0, 0.3), // 5+ vert (mains)
};
// ===== Mesh overlay (triangles face/hand/body) =====================
// Reuse meme layout que skel : pos.xyz + conf + pid.
vertex SkelOut mesh_vertex(SkelIn in [[stage_in]],
constant SceneUniforms& U [[buffer(1)]]) {
SkelOut o;
float z = clamp(in.pos.z, -1.0, 1.0);
float pulse = 1.0 + U.rms * 0.25;
float persp = 1.0 / (1.0 + z * 0.8);
float2 xy = in.pos.xy * persp * pulse;
o.position = float4(xy, 0.0, 1.0);
o.conf = in.conf;
o.pid = in.pid;
o.depth = z;
return o;
}
fragment float4 mesh_fragment(SkelOut in [[stage_in]]) {
int pid = int(in.pid + 0.5);
pid = ((pid % 6) + 6) % 6;
float3 col = PERSON_COLORS[pid];
float c = saturate(in.conf);
// Saturation boost : couleurs vives quand pose detectee
col = mix(col, col * 1.6, c);
// Fog par profondeur (proche = plus lumineux)
float depth_fog = 1.0 - clamp(in.depth + 0.5, 0.0, 1.0) * 0.5;
col *= depth_fog;
// Alpha TRES VISIBLE quand confiance haute : 0.85 sur skin, 0.3 fade
return float4(col, mix(0.3, 0.85, c));
}
fragment float4 skel_fragment(SkelOut in [[stage_in]]) {
// Skeleton ULTRA visible quand pose detectee : couleur vive + opaque
int pid = int(in.pid + 0.5);
pid = ((pid % 6) + 6) % 6; // modulo positif
float3 col = PERSON_COLORS[pid] * 1.4; // saturation boost
float c = saturate(in.conf);
// Depth fog : eclaircit ce qui est proche, eteint ce qui est loin
float depth_fog = 1.0 - clamp(in.depth + 0.5, 0.0, 1.0) * 0.6;
col *= depth_fog * (0.5 + 0.5 * c);
// Alpha plein-opaque quand confiance haute (= squelette ultra net)
return float4(col, mix(0.5, 1.0, c));
}
@@ -0,0 +1,120 @@
import Foundation
import Metal
import MetalKit
/// Renderer Metal pour les 10 viz modes background (storm, tunnel,
/// plasma, kaleido, voronoi, metaballs, starfield, bars, hands3d,
/// openpos). Reutilise le shader scene.metal porte depuis
/// data_only_viz Python. Sert de couche backing sous l'ARView dans
/// BodyView.
final class SceneRenderer: NSObject, MTKViewDelegate {
// Mirror C struct of scene.metal SceneUniforms (20 floats)
struct SceneUniforms {
var time: Float = 0
var rms: Float = 0
var kp_norm: Float = 0
var netz_dev: Float = 0
var lightning_flash: Float = 0
var flare: Float = 0
var wind_norm: Float = 0
var bz_norm: Float = 0
var social_rate: Float = 0
var pose_alive: Float = 0
var pose_count: Float = 0
var width: Float = 1280
var height: Float = 720
var viz_mode: Float = 0
var hand_l_x: Float = 0
var hand_l_y: Float = 0
var hand_r_x: Float = 0
var hand_r_y: Float = 0
var _pad0: Float = 0
var _pad1: Float = 0
}
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let bgPipeline: MTLRenderPipelineState
private let uniformsBuffer: MTLBuffer
private var startTime: CFTimeInterval = CACurrentMediaTime()
/// Mis a jour en live depuis RenderSettings / OSC handler.
var uniforms = SceneUniforms()
static func make() -> SceneRenderer? {
return SceneRenderer.init(failable: ())
}
private init?(failable: Void) {
guard let dev = MTLCreateSystemDefaultDevice(),
let queue = dev.makeCommandQueue() else { return nil }
self.device = dev
self.commandQueue = queue
// Compile scene.metal au runtime depuis le bundle
guard let url = Bundle.module.url(forResource: "scene",
withExtension: "metal"),
let source = try? String(contentsOf: url, encoding: .utf8) else {
print("SceneRenderer: scene.metal absent du bundle")
return nil
}
let opts = MTLCompileOptions()
let lib: MTLLibrary
do {
lib = try dev.makeLibrary(source: source, options: opts)
} catch {
print("SceneRenderer: scene.metal compile error: \(error)")
return nil
}
guard let vfn = lib.makeFunction(name: "bg_vertex"),
let ffn = lib.makeFunction(name: "bg_fragment") else {
print("SceneRenderer: bg_vertex/bg_fragment missing")
return nil
}
let pd = MTLRenderPipelineDescriptor()
pd.vertexFunction = vfn
pd.fragmentFunction = ffn
pd.colorAttachments[0].pixelFormat = .bgra8Unorm
// Pas de blending : le MTKView est opaque, l'ARView par-dessus
// est transparent.
do {
self.bgPipeline = try dev.makeRenderPipelineState(descriptor: pd)
} catch {
print("SceneRenderer: pipeline build failed: \(error)")
return nil
}
guard let buf = dev.makeBuffer(
length: MemoryLayout<SceneUniforms>.stride,
options: .storageModeShared) else { return nil }
self.uniformsBuffer = buf
super.init()
}
// MARK: - MTKViewDelegate
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
uniforms.width = Float(size.width)
uniforms.height = Float(size.height)
}
func draw(in view: MTKView) {
uniforms.time = Float(CACurrentMediaTime() - startTime)
// Copy uniforms vers buffer GPU (shared memory)
let ptr = uniformsBuffer.contents().bindMemory(
to: SceneUniforms.self, capacity: 1)
ptr.pointee = uniforms
guard let rpd = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let cb = commandQueue.makeCommandBuffer(),
let enc = cb.makeRenderCommandEncoder(descriptor: rpd) else {
return
}
enc.setRenderPipelineState(bgPipeline)
enc.setFragmentBuffer(uniformsBuffer, offset: 0, index: 0)
enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
enc.endEncoding()
cb.present(drawable)
cb.commit()
}
}
@@ -57,6 +57,9 @@ struct SettingsPanel: View {
layerRow(icon: "video.fill",
label: "Webcam",
isOn: $settings.showCamera)
layerRow(icon: "sparkles",
label: "Scene Metal (\(settings.vizModeName))",
isOn: $settings.showScene)
layerRow(icon: "person.fill",
label: "Maillage SMPL-X",
isOn: $settings.showMesh)
@@ -66,6 +69,22 @@ struct SettingsPanel: View {
layerRow(icon: "figure.stand",
label: "Squelette (à venir)",
isOn: $settings.showSkeleton)
// Picker viz mode 0..9
HStack(spacing: 4) {
ForEach(0..<10) { i in
Button(action: { settings.vizMode = i }) {
Text(String(i))
.font(.caption2.monospacedDigit())
.frame(width: 22, height: 22)
.background(
Circle().fill(settings.vizMode == i
? Color.pink
: Color.white.opacity(0.1)))
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
}
}