Loading...
Searching...
No Matches
CameraSwitch.hpp
1#pragma once
2#include <halp/controls.hpp>
3#include <halp/meta.hpp>
4
5#include <ossia/dataflow/geometry_port.hpp>
6
7#include <QQuaternion>
8#include <QVector3D>
9
10#include <cmath>
11#include <cstdint>
12#include <memory>
13
14namespace Threedim
15{
16
17// 4-way camera switch and weighted blender.
18//
19// Operates at the scene_spec level like SceneSwitch, specialised to selecting or
20// blending up to 4 Camera producers. Each input is expected to be the output of
21// a Threedim::Camera, or any scene_spec whose first root carries a
22// scene_transform + camera_component pair.
23//
24// Select mode: `index` picks one input and the other three are ignored.
25// Equivalent to a SceneSwitch in front of a camera, without the caveat that
26// non-camera scene data from the unselected inputs would be dropped too.
27//
28// Blend mode: `weights` (x,y,z,w) linearly blends positions, FOV and near/far,
29// and nlerps the orientation quaternions. Weights are normalised to sum 1
30// internally, so raw envelope or LFO outputs can be passed directly. nlerp is
31// fine for small angular deltas; great-circle blending across wide angles would
32// take two slerps.
33//
34// An unwired input contributes zero weight, and an output with no wired input
35// carrying effective weight is empty.
37{
38public:
39 halp_meta(name, "Camera Switch")
40 halp_meta(category, "Visuals/3D/Scene")
41 halp_meta(c_name, "camera_switch")
42 halp_meta(authors, "ossia team")
43 halp_meta(
44 manual_url,
45 "https://ossia.io/score-docs/processes/camera-switch.html")
46 halp_meta(uuid, "d1e8c4b7-6a32-4f9e-b5d8-2c4f3a1e8b6d")
47
48 struct ins
49 {
50 struct
51 {
52 halp_meta(name, "Camera 0");
53 ossia::scene_spec scene;
54 uint8_t dirty{0};
55 } cam0;
56 struct
57 {
58 halp_meta(name, "Camera 1");
59 ossia::scene_spec scene;
60 uint8_t dirty{0};
61 } cam1;
62 struct
63 {
64 halp_meta(name, "Camera 2");
65 ossia::scene_spec scene;
66 uint8_t dirty{0};
67 } cam2;
68 struct
69 {
70 halp_meta(name, "Camera 3");
71 ossia::scene_spec scene;
72 uint8_t dirty{0};
73 } cam3;
74
75 enum CameraMode
76 {
77 Select,
78 Blend
79 };
80 // Port-driven rebuild: controls trigger CameraSwitch::rebuild().
81 // Upstream camera-input changes are detected in operator()().
82 struct Mode : halp::enum_t<CameraMode, "Mode">
83 {
84 struct range
85 {
86 std::string_view values[2]{"Select", "Blend"};
87 CameraMode init{Select};
88 };
89 void update(CameraSwitch& n) { n.rebuild(); }
90 } mode;
91
92 struct : halp::spinbox_i32<"Index", halp::irange{0, 3, 0}>
93 { void update(CameraSwitch& n) { n.rebuild(); } } index;
94
95 // Four-channel blend weights. Negative values are clamped to zero.
96 struct : halp::xyzw_spinboxes_f32<"Weights", halp::range{-10000., 10000., 0.}>
97 { void update(CameraSwitch& n) { n.rebuild(); } } weights;
98 } inputs;
99
100 struct outs
101 {
102 struct
103 {
104 halp_meta(name, "Scene Out");
105 ossia::scene_spec scene;
106 uint8_t dirty{0};
107 } scene_out;
108 } outputs;
109
110 // Stable id for the synthesised camera in Blend mode. One id kept for the
111 // whole life of the node so downstream preprocessor logic treats frames
112 // as updates to the same camera rather than a sequence of add/remove
113 // events.
114 ossia::scene_node_id m_id{};
115 std::shared_ptr<ossia::scene_state> m_state;
116 int64_t m_version{0};
117 uint8_t m_pending_dirty{ossia::scene_port::dirty_transform};
118 // Cached upstream identity for detecting scene_in pointer/version
119 // changes from within operator()()'s republish path.
120 const ossia::scene_state* m_cached_cam_state[4]{};
121 int64_t m_cached_cam_ver[4]{-1, -1, -1, -1};
122
123 // Locate the first (scene_transform, camera_component) pair in a scene.
124 // Returns false if the input has no camera (or is empty).
125 static bool extractCameraPose(
126 const ossia::scene_spec& in, ossia::scene_transform& xform,
127 ossia::camera_component& cam)
128 {
129 if(!in.state || !in.state->roots || in.state->roots->empty())
130 return false;
131 const auto& root = (*in.state->roots)[0];
132 if(!root || !root->children)
133 return false;
134
135 bool gotXform = false;
136 bool gotCam = false;
137 for(const auto& child : *root->children)
138 {
139 if(auto* t = ossia::get_if<ossia::scene_transform>(&child))
140 {
141 xform = *t;
142 gotXform = true;
143 }
144 else if(auto* c = ossia::get_if<ossia::camera_component_ptr>(&child))
145 {
146 if(*c)
147 {
148 cam = **c;
149 gotCam = true;
150 }
151 }
152 }
153 return gotXform && gotCam;
154 }
155
156 void rebuild()
157 {
158 const int mode = inputs.mode.value;
159 if(mode == ins::CameraMode::Select)
160 {
161 // Select-mode: operator()() forwards the picked upstream
162 // scene_spec directly; rebuild() just marks pending dirty so
163 // downstream sees a transition event.
164 m_pending_dirty = 0xFF;
165 return;
166 }
167
168 // Blend mode.
169 float w[4]{
170 inputs.weights.value.x, inputs.weights.value.y,
171 inputs.weights.value.z, inputs.weights.value.w};
172 for(float& x : w) x = x > 0.f ? x : 0.f;
173
174 const ossia::scene_spec* inputsArr[4]{
175 &inputs.cam0.scene, &inputs.cam1.scene,
176 &inputs.cam2.scene, &inputs.cam3.scene};
177
178 // Extract each input's pose; zero the weight of any missing one.
179 ossia::scene_transform xforms[4]{};
180 ossia::camera_component cams[4]{};
181 float effWeights[4]{};
182 float wsum = 0.f;
183 for(int i = 0; i < 4; ++i)
184 {
185 if(w[i] <= 0.f) continue;
186 if(!extractCameraPose(*inputsArr[i], xforms[i], cams[i]))
187 continue;
188 effWeights[i] = w[i];
189 wsum += w[i];
190 }
191
192 if(wsum <= 1e-6f)
193 {
194 // No wired-and-weighted camera to blend — emit empty.
195 if(m_state)
196 {
197 m_state->roots.reset();
198 m_state->active_camera_id = {};
199 m_version++;
200 m_state->version = m_version;
201 }
202 // Bump dirty so consumers detect the empty-state transition.
203 m_pending_dirty = 0xFF;
204 return;
205 }
206 for(float& x : effWeights) x /= wsum;
207
208 // Blend transform: translation is weighted sum; rotation is nlerp
209 // (weighted sum of quaternions, then normalise); scale is weighted sum.
210 // Accumulators must start at zero: value-initialisation APPLIES the
211 // default member initialisers (scale 1,1,1), so a weighted sum would land
212 // on mean + default.
213 ossia::scene_transform outX{};
214 outX.scale[0] = outX.scale[1] = outX.scale[2] = 0.f;
215 QQuaternion qSum(0, 0, 0, 0);
216 for(int i = 0; i < 4; ++i)
217 {
218 if(effWeights[i] <= 0.f) continue;
219 const float wi = effWeights[i];
220 outX.translation[0] += xforms[i].translation[0] * wi;
221 outX.translation[1] += xforms[i].translation[1] * wi;
222 outX.translation[2] += xforms[i].translation[2] * wi;
223 outX.scale[0] += xforms[i].scale[0] * wi;
224 outX.scale[1] += xforms[i].scale[1] * wi;
225 outX.scale[2] += xforms[i].scale[2] * wi;
226
227 // Quaternion double-cover handling: flip the sign of later quats if
228 // they point away from the running sum, to avoid interpolating the
229 // long way around.
230 QQuaternion qi(
231 xforms[i].rotation[3], xforms[i].rotation[0],
232 xforms[i].rotation[1], xforms[i].rotation[2]);
233 if(QQuaternion::dotProduct(qSum, qi) < 0.f)
234 qi = -qi;
235 qSum += qi * wi;
236 }
237 qSum.normalize();
238 outX.rotation[0] = qSum.x();
239 outX.rotation[1] = qSum.y();
240 outX.rotation[2] = qSum.z();
241 outX.rotation[3] = qSum.scalar();
242
243 // Blend camera parameters.
244 // Same here: camera_component defaults yfov to 45 degrees, zfar to 1000,
245 // aspect/xmag/ymag to 1 and focal_length to 50.
246 ossia::camera_component outCam{};
247 outCam.yfov = 0.f;
248 outCam.aspect_ratio = 0.f;
249 outCam.xmag = 0.f;
250 outCam.ymag = 0.f;
251 outCam.znear = 0.f;
252 outCam.zfar = 0.f;
253 outCam.physical.focal_length = 0.f;
254 outCam.physical.focus_distance = 0.f;
255 outCam.physical.fstop = 0.f;
256 // Projection mode is not blendable -- correct -- but that does not make
257 // camera 0 the right representative. With weights {0, 1, 0, 0} camera 0
258 // contributes NOTHING and is very likely still at its defaults, which
259 // would make an orthographic camera 1 come out perspective. Take the mode
260 // from the camera that actually dominates the blend.
261 {
262 int dominant = 0;
263 float best = -1.f;
264 for(int i = 0; i < 4; ++i)
265 {
266 if(effWeights[i] > best)
267 {
268 best = effWeights[i];
269 dominant = i;
270 }
271 }
272 outCam.projection = cams[dominant].projection;
273 }
274 for(int i = 0; i < 4; ++i)
275 {
276 if(effWeights[i] <= 0.f) continue;
277 const float wi = effWeights[i];
278 outCam.yfov += cams[i].yfov * wi;
279 outCam.aspect_ratio += cams[i].aspect_ratio * wi;
280 outCam.xmag += cams[i].xmag * wi;
281 outCam.ymag += cams[i].ymag * wi;
282 outCam.znear += cams[i].znear * wi;
283 outCam.zfar += cams[i].zfar * wi;
284 outCam.physical.focal_length += cams[i].physical.focal_length * wi;
285 outCam.physical.focus_distance += cams[i].physical.focus_distance * wi;
286 outCam.physical.fstop += cams[i].physical.fstop * wi;
287 }
288
289 // Build the output scene_spec.
290 if(!m_state)
291 {
292 m_state = std::make_shared<ossia::scene_state>();
293 m_id.value = reinterpret_cast<std::uintptr_t>(this) | 0x1u;
294 }
295
296 auto camPtr = std::make_shared<ossia::camera_component>(std::move(outCam));
297 auto children = std::make_shared<std::vector<ossia::scene_payload>>();
298 children->push_back(outX);
299 children->push_back(ossia::camera_component_ptr(std::move(camPtr)));
300
301 auto node = std::make_shared<ossia::scene_node>();
302 node->id = m_id;
303 node->children = std::move(children);
304
305 auto roots = std::make_shared<std::vector<ossia::scene_node_ptr>>();
306 roots->push_back(std::move(node));
307
308 m_state->roots = std::move(roots);
309 m_state->active_camera_id = m_id;
310 m_version++;
311 m_state->version = m_version;
312 m_pending_dirty = ossia::scene_port::dirty_transform;
313 }
314
315 void operator()()
316 {
317 // Detect upstream camera-input pointer/version changes so a
318 // scene_in that changed without a local control event still causes
319 // a rebuild. Controls themselves trigger rebuild via their
320 // update() callbacks.
321 const ossia::scene_spec* cams[4]{
322 &inputs.cam0.scene, &inputs.cam1.scene,
323 &inputs.cam2.scene, &inputs.cam3.scene};
324 bool upstream_changed = false;
325 for(int i = 0; i < 4; ++i)
326 {
327 const auto* s = cams[i]->state.get();
328 const int64_t v = s ? s->version : -1;
329 if(m_cached_cam_state[i] != s || m_cached_cam_ver[i] != v)
330 {
331 upstream_changed = true;
332 m_cached_cam_state[i] = s;
333 m_cached_cam_ver[i] = v;
334 }
335 }
336
337 if(inputs.mode.value == ins::CameraMode::Select)
338 {
339 // Forward the picked upstream scene directly — no local
340 // shared_ptr identity to preserve beyond what upstream already
341 // maintains.
342 const int idx = inputs.index.value;
343 const ossia::scene_spec* picked = nullptr;
344 switch(idx)
345 {
346 case 0: picked = &inputs.cam0.scene; break;
347 case 1: picked = &inputs.cam1.scene; break;
348 case 2: picked = &inputs.cam2.scene; break;
349 case 3: picked = &inputs.cam3.scene; break;
350 default: picked = &inputs.cam0.scene; break;
351 }
352 outputs.scene_out.scene.state = picked->state;
353 outputs.scene_out.dirty
354 = (upstream_changed && picked->state) ? 0xFF : 0;
355 m_pending_dirty = 0;
356 return;
357 }
358
359 if(!m_state || upstream_changed)
360 rebuild();
361 outputs.scene_out.scene.state = m_state;
362 outputs.scene_out.dirty = m_pending_dirty;
363 m_pending_dirty = 0;
364 }
365};
366
367}
Definition CameraSwitch.hpp:37
Definition CameraSwitch.hpp:85
Definition CameraSwitch.hpp:83
Definition CameraSwitch.hpp:49
Definition CameraSwitch.hpp:101