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