OpenSceneGraph 0.1.2__cp313-cp313-win_amd64.whl

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 (90) hide show
  1. OpenSceneGraph/OpenThreads.lib +0 -0
  2. OpenSceneGraph/_OpenSceneGraph.cp313-win_amd64.pyd +0 -0
  3. OpenSceneGraph/__init__.py +52 -0
  4. OpenSceneGraph/aipython/00-index.md +44 -0
  5. OpenSceneGraph/aipython/01-core.md +255 -0
  6. OpenSceneGraph/aipython/02-inspect.md +81 -0
  7. OpenSceneGraph/aipython/03-headless-frames.md +149 -0
  8. OpenSceneGraph/aipython/05-camera-manipulator.md +68 -0
  9. OpenSceneGraph/aipython/06-camera-effects.md +132 -0
  10. OpenSceneGraph/aipython/07-camera-manual.md +88 -0
  11. OpenSceneGraph/aipython/08-lighting.md +121 -0
  12. OpenSceneGraph/aipython/09-picking.md +90 -0
  13. OpenSceneGraph/aipython/10-rtt.md +199 -0
  14. OpenSceneGraph/aipython/11-mrt.md +104 -0
  15. OpenSceneGraph/aipython/12-gbuffer.md +48 -0
  16. OpenSceneGraph/aipython/15-shader-hotswap.md +72 -0
  17. OpenSceneGraph/aipython/17-particles.md +189 -0
  18. OpenSceneGraph/aipython/18-deterministic-captures.md +157 -0
  19. OpenSceneGraph/aipython/20-object-lifetime.md +77 -0
  20. OpenSceneGraph/aipython/25-async-osgpy.md +228 -0
  21. OpenSceneGraph/aipython/29-material.md +109 -0
  22. OpenSceneGraph/aipython/30-pbribl.md +162 -0
  23. OpenSceneGraph/aipython/40-typed-lights-gizmos.md +218 -0
  24. OpenSceneGraph/examples/__init__.py +24 -0
  25. OpenSceneGraph/examples/__main__.py +143 -0
  26. OpenSceneGraph/examples/blur.py +370 -0
  27. OpenSceneGraph/examples/info.py +126 -0
  28. OpenSceneGraph/examples/mrt.py +493 -0
  29. OpenSceneGraph/examples/pyosg_async.py +467 -0
  30. OpenSceneGraph/examples/pyosg_example.py +120 -0
  31. OpenSceneGraph/examples/pyosg_repl.py +842 -0
  32. OpenSceneGraph/examples/pyosg_visitor.py +138 -0
  33. OpenSceneGraph/ktx.dll +0 -0
  34. OpenSceneGraph/ktx.lib +0 -0
  35. OpenSceneGraph/osg.lib +0 -0
  36. OpenSceneGraph/osg161-osg.dll +0 -0
  37. OpenSceneGraph/osg161-osgAnimation.dll +0 -0
  38. OpenSceneGraph/osg161-osgDB.dll +0 -0
  39. OpenSceneGraph/osg161-osgFX.dll +0 -0
  40. OpenSceneGraph/osg161-osgGA.dll +0 -0
  41. OpenSceneGraph/osg161-osgText.dll +0 -0
  42. OpenSceneGraph/osg161-osgUtil.dll +0 -0
  43. OpenSceneGraph/osg161-osgViewer.dll +0 -0
  44. OpenSceneGraph/osg161-osgWidget.dll +0 -0
  45. OpenSceneGraph/osgAnimation.lib +0 -0
  46. OpenSceneGraph/osgDB.lib +0 -0
  47. OpenSceneGraph/osgFX.lib +0 -0
  48. OpenSceneGraph/osgGA.lib +0 -0
  49. OpenSceneGraph/osgPlugins-3.6.5/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
  50. OpenSceneGraph/osgPlugins-3.6.5/ktx.dll +0 -0
  51. OpenSceneGraph/osgPlugins-3.6.5/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
  52. OpenSceneGraph/osgPlugins-3.6.5/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
  53. OpenSceneGraph/osgPlugins-3.6.5/msvcp140.dll +0 -0
  54. OpenSceneGraph/osgPlugins-3.6.5/osgdb_bmp.dll +0 -0
  55. OpenSceneGraph/osgPlugins-3.6.5/osgdb_dds.dll +0 -0
  56. OpenSceneGraph/osgPlugins-3.6.5/osgdb_gltf.dll +0 -0
  57. OpenSceneGraph/osgPlugins-3.6.5/osgdb_hdr.dll +0 -0
  58. OpenSceneGraph/osgPlugins-3.6.5/osgdb_jpeg.dll +0 -0
  59. OpenSceneGraph/osgPlugins-3.6.5/osgdb_ktx2.dll +0 -0
  60. OpenSceneGraph/osgPlugins-3.6.5/osgdb_obj.dll +0 -0
  61. OpenSceneGraph/osgPlugins-3.6.5/osgdb_osg.dll +0 -0
  62. OpenSceneGraph/osgPlugins-3.6.5/osgdb_png.dll +0 -0
  63. OpenSceneGraph/osgPlugins-3.6.5/osgdb_pnm.dll +0 -0
  64. OpenSceneGraph/osgPlugins-3.6.5/osgdb_rgb.dll +0 -0
  65. OpenSceneGraph/osgPlugins-3.6.5/osgdb_serializers_osg.dll +0 -0
  66. OpenSceneGraph/osgPlugins-3.6.5/osgdb_stl.dll +0 -0
  67. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tga.dll +0 -0
  68. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tiff.dll +0 -0
  69. OpenSceneGraph/osgPlugins-3.6.5/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
  70. OpenSceneGraph/osgPlugins-3.6.5/z.dll +0 -0
  71. OpenSceneGraph/osgText.lib +0 -0
  72. OpenSceneGraph/osgUtil.lib +0 -0
  73. OpenSceneGraph/osgViewer.lib +0 -0
  74. OpenSceneGraph/osgWidget.lib +0 -0
  75. OpenSceneGraph/osgx.cp313-win_amd64.pyd +0 -0
  76. OpenSceneGraph/osgx_static.lib +0 -0
  77. OpenSceneGraph/ot21-OpenThreads.dll +0 -0
  78. openscenegraph-0.1.2.dist-info/DELVEWHEEL +2 -0
  79. openscenegraph-0.1.2.dist-info/METADATA +563 -0
  80. openscenegraph-0.1.2.dist-info/RECORD +90 -0
  81. openscenegraph-0.1.2.dist-info/WHEEL +5 -0
  82. openscenegraph-0.1.2.dist-info/entry_points.txt +6 -0
  83. openscenegraph-0.1.2.dist-info/licenses/LICENSE +21 -0
  84. openscenegraph.libs/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
  85. openscenegraph.libs/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
  86. openscenegraph.libs/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
  87. openscenegraph.libs/msvcp140.dll +0 -0
  88. openscenegraph.libs/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
  89. openscenegraph.libs/z.dll +0 -0
  90. osgx.py +7 -0
@@ -0,0 +1,104 @@
1
+ # Multiple render targets, depth, and pass timing
2
+
3
+ `pyosg-mrt.py` proves one geometry pass writing multiple color attachments;
4
+ `pyosg-guided-blur.py` builds on that with normal/depth-guided post-processing.
5
+ Read this before treating a G-buffer depth texture as a simple linear distance
6
+ field.
7
+
8
+ ## Raw depth is correct, but it is not linear distance
9
+
10
+ Attach depth normally and let the geometry pass write it through the ordinary
11
+ depth test:
12
+
13
+ ```python
14
+ cam.attach(osg.Camera.COLOR_BUFFER0, paint_tex)
15
+ cam.attach(osg.Camera.COLOR_BUFFER1, normal_tex)
16
+ cam.attach(osg.Camera.DEPTH_BUFFER, depth_tex)
17
+ ```
18
+
19
+ The sampled `depth_tex` value is post-projection depth. It is excellent for
20
+ visibility and has most precision near the camera, but comparing two samples
21
+ as though `abs(a - b)` were a world-space distance gives depth-dependent
22
+ results. A post-process needs the near/far parameters from the *same effective
23
+ projection* that wrote the texture:
24
+
25
+ ```glsl
26
+ float linearizeDepth(float d, float znear, float zfar) {
27
+ float z = d * 2.0 - 1.0;
28
+ return (2.0 * znear * zfar) / (zfar + znear - z * (zfar - znear));
29
+ }
30
+ ```
31
+
32
+ The raw-depth display may be nearly white; that is normal perspective-depth
33
+ distribution, not proof the attachment is empty. Always expose both a raw
34
+ depth debug view and a linear-depth debug view while bringing up a pass.
35
+
36
+ ## OSG's nominal camera projection is not necessarily the depth projection
37
+
38
+ `viewer.camera.projectionMatrix` commonly remains the nominal lens (for
39
+ example `0.1 - 1000`), while cull traversal tightens the actual projection
40
+ used for a camera pass to improve depth precision. Do not use the nominal
41
+ matrix to linearize an RTT depth texture when the pass uses OSG's automatic
42
+ near/far computation.
43
+
44
+ The reliable observation point is the depth-writing camera's **post-draw**
45
+ callback: its `RenderInfo::State` still holds the cull-adjusted projection,
46
+ and later render-order passes can consume uniforms updated there. Pre-draw is
47
+ too early; its state can still be the preceding pass's projection.
48
+
49
+ ```python
50
+ def update_depth_parameters(ri):
51
+ _fovy, _aspect, near, far = ri.state.projectionMatrix.getPerspective()
52
+ composite.stateSet.uniforms["znear"] = float(near)
53
+ composite.stateSet.uniforms["zfar"] = float(far)
54
+
55
+ gbuffer_cam.postDrawCallback = update_depth_parameters
56
+ ```
57
+
58
+ For a later guided blur, use a relative difference so the control has a stable
59
+ meaning across distance:
60
+
61
+ ```glsl
62
+ float delta = abs(zCenter - zSample) / max(zCenter, 0.001);
63
+ float depthWeight = exp(-delta * depthRejection);
64
+ ```
65
+
66
+ Use the same continuous shape for normal rejection. `pow(dot(N0, N1),
67
+ strength)` has a misleading zero special case: any positive strength rejects a
68
+ perpendicular sample completely, making `0` look like a binary mode switch.
69
+ This bilateral form makes strength zero genuinely disable the guide and raises
70
+ rejection smoothly:
71
+
72
+ ```glsl
73
+ float normalDifference = 1.0 - clamp(dot(centerNormal, sampleNormal), 0.0, 1.0);
74
+ float normalWeight = exp(-normalDifference * normalRejection);
75
+ ```
76
+
77
+ ## Draw callback slots have one owner
78
+
79
+ `osg::Camera` has one pre-draw and one post-draw callback slot. Assigning
80
+ `camera.preDrawCallback = other` replaces the existing callback; it does not
81
+ append or compose it. `osgx.imgui.Widget` installs its own `PreDraw` and
82
+ `PostDraw` callbacks on its configured draw camera during initialization, so
83
+ installing another callback on that same camera before constructing the Widget
84
+ silently loses it (and releases the replaced callback immediately).
85
+
86
+ Use `osgx.CallbackGroup` when multiple systems must share a draw slot, or put
87
+ a pass-specific callback on the pass camera instead. In the guided-blur
88
+ example, the G-buffer camera's post-draw callback is the better location: it
89
+ cannot collide with the Widget's composite-camera callbacks and it sees the
90
+ right projection at the right time.
91
+
92
+ ## G-buffer debugging order
93
+
94
+ When a depth-aware pass looks wrong, establish these facts in order:
95
+
96
+ 1. The raw depth attachment changes with the scene (`depthTex` debug view).
97
+ 2. The live `znear`/`zfar` values are not constructor defaults.
98
+ 3. Linearized depth has visible structure before tuning any rejection slider.
99
+ 4. With normal rejection at zero, depth rejection alone changes overlap/layer
100
+ behavior; then test normal rejection independently.
101
+
102
+ Do not node-mask the G-buffer camera to inspect a later pass: its texture then
103
+ contains no current-frame data. Keep every producer pass running and switch
104
+ only the final backbuffer composite's display mode.
@@ -0,0 +1,48 @@
1
+ # G-buffer contracts and deferred composition
2
+
3
+ A G-buffer is a geometry pass that stores reusable per-visible-pixel data for
4
+ later passes. It is not inherently PBR, IBL, glTF, or lighting: normal/depth
5
+ guided blur, outlines, SSAO, watercolor diffusion, and deferred lighting can
6
+ all consume the same kind of attachments.
7
+
8
+ ## Keep stored data canonical; derive interpretations in consumers
9
+
10
+ Store the native depth attachment as raw depth. A consumer that needs linear
11
+ camera distance should request the camera depth parameters and linearize it;
12
+ one needing a full view-space position can reconstruct it from depth plus an
13
+ inverse projection, or use a deliberately stored position attachment. This
14
+ keeps `osgx::GBuffer` neutral and lets each pass choose the representation its
15
+ algorithm actually needs.
16
+
17
+ Likewise, normals are a geometric guide rather than an outline instruction.
18
+ A guided blur can compare `dot(centerNormal, sampleNormal)` to preserve a
19
+ crease, while an NPR edge pass can turn that same difference into an ink line.
20
+
21
+ ## Minimal useful layout
22
+
23
+ The smallest general deferred/post-process layout is often:
24
+
25
+ ```text
26
+ COLOR_BUFFER0 paint/albedo/working color
27
+ COLOR_BUFFER1 view-space normal
28
+ DEPTH_BUFFER native visibility depth
29
+ ```
30
+
31
+ Add attachments only for a demonstrated consumer: material factors for
32
+ deferred PBR, emissive data for lighting, position for algorithms where
33
+ reconstruction is inconvenient or too imprecise, IDs for picking, and so on.
34
+ `pyosg-mrt.py` proves simultaneous color writes; `pyosg-guided-blur.py` proves
35
+ the first downstream normal/depth-aware pass.
36
+
37
+ ## Deferred pipeline shape
38
+
39
+ ```text
40
+ geometry -> G-buffer attachments
41
+ G-buffer -> one or more RTT post-process passes
42
+ all results -> final POST_RENDER composite/debug display
43
+ ```
44
+
45
+ Every RTT producer must remain enabled while a later pass samples it. Debug
46
+ by changing the final composite output, not by disabling the producer whose
47
+ texture you are trying to inspect. For depth timing and callback ownership,
48
+ read [`11-mrt.md`](11-mrt.md).
@@ -0,0 +1,72 @@
1
+ # Live GLSL hot-swapping for shader-side debugging
2
+
3
+ For debugging shader-side logic in a live REPL session, patch the actual GLSL
4
+ source and hot-swap a new `osg.Program` onto the live node's `StateSet` —
5
+ don't reimplement the shader's math in Python/NumPy and compare numbers.
6
+ Matrix-convention mistakes (row vs. column vector, upload transpose) are easy
7
+ to get subtly wrong reimplementing shader math by hand; a live edit that
8
+ visualizes the GPU's *actual* computation (e.g. color-coding which branch of
9
+ an `if` a fragment took) sidesteps that ambiguity.
10
+
11
+ Find the `Program`'s owning node — often a child `Geode`, not the `Camera`
12
+ itself (the shader is typically attached to the fullscreen-quad `Geode`, not
13
+ the camera's own `StateSet`). Then, live in the REPL:
14
+
15
+ ```python
16
+ src = COMPOSITE_FRAGMENT_SHADER # the original global string
17
+ assert src.count(marker) == 1 # a non-unique match silently patches the wrong occurrence
18
+ src2 = src.replace(marker, patched_substring, 1)
19
+ p = osg.Program(shaders=(
20
+ osg.Shader(osg.Shader.VERTEX, FULLSCREEN_VERTEX),
21
+ osg.Shader(osg.Shader.FRAGMENT, src2)
22
+ ))
23
+ node.stateSet.setAttributeAndModes(p, osg.StateAttribute.ON | osg.StateAttribute.OVERRIDE)
24
+ ```
25
+
26
+ No restart needed, no loss of current camera position/state.
27
+
28
+ To prove the old `Program`/`Shader`s actually got destroyed (not just
29
+ detached), add `debug=True` to each constructor call — see
30
+ [`20-object-lifetime.md`](20-object-lifetime.md). Requires the type to be
31
+ wired into `kwargs_init`; check the manifest if `debug=` raises a constructor
32
+ mismatch instead of trusting the swap-and-drop-the-old-ref pattern on faith.
33
+
34
+ ## The deeper trap: a live variable reassignment silently not reaching the running callback
35
+
36
+ Two layers, both real:
37
+
38
+ **Layer 1 — wrong variable.** A per-frame callback may read a
39
+ closure-captured local from setup time (e.g. `light_proj`, captured when the
40
+ callback was defined) rather than the live object property it looks like it
41
+ should read (e.g. `shadow_cam.projectionMatrix`, which may have since
42
+ changed). Reassigning the object property silently no-ops if the callback
43
+ never reads it. Grep the actual callback body for what it reads — don't
44
+ assume based on the object's name alone.
45
+
46
+ **Layer 2 — even the right variable name can fail.** Reassigning the correct
47
+ bare name (`light_proj = new_value`) at the prompt — even via
48
+ `exec(open(path).read())` — can silently fail to reach the namespace a
49
+ running callback actually reads from, **despite `globals() is
50
+ callback.__globals__` printing `True`**. Verify via
51
+ `callback.__globals__['light_proj']` showing a different `id()` than a bare
52
+ `light_proj` lookup in the same command before trusting a "fix had zero
53
+ effect" conclusion.
54
+
55
+ The reliable pattern — write through the callback's own `__globals__`
56
+ explicitly:
57
+
58
+ ```python
59
+ cb = shadow_cam.preDrawCallback
60
+ g = cb.__globals__ # the namespace the callback ACTUALLY reads from
61
+ g['light_proj'] = new_value
62
+ g['shadow_cam'].projectionMatrix = new_value # keep any live object property in sync too
63
+ ```
64
+
65
+ Then re-verify by rebuilding the dependent live GPU uniform from `g[...]`
66
+ and diffing it against the actual uniform value before drawing any
67
+ conclusion from the visual result.
68
+
69
+ See [`01-core.md`](01-core.md) rule 2 for the related but distinct
70
+ free-variable `NameError` trap — that one is about a name never resolving at
71
+ all; this one is about a name resolving to a *stale* value in a namespace
72
+ that looks, but isn't, the same one.
@@ -0,0 +1,189 @@
1
+ # Building GPU-only, one-shot particle/burst effects live
2
+
3
+ Patterns for instanced GPU effects (fire, explosions, shockwaves — a swarm of
4
+ quads driven by a formula) built live via REPL. First built out in
5
+ `examples/pyosg-fire.py`; design/TODO for that specific effect lives in
6
+ `ai/context-todo-particles.md`, not here — this file is the reusable
7
+ technique.
8
+
9
+ ## 1. Per-instance "seed" data: hash `gl_InstanceID`, don't reach for an SSBO
10
+
11
+ If per-instance data (emission direction, size variance, phase) is
12
+ read-once and never changes after upload, derive it from `gl_InstanceID`
13
+ with an in-shader hash instead of an SSBO + CPU-side random buffer:
14
+
15
+ ```glsl
16
+ float hash11(float p) {
17
+ p = fract(p * 0.1031);
18
+ p *= p + 33.33;
19
+ p *= p + p;
20
+ return fract(p);
21
+ }
22
+
23
+ vec4 hash14(float p) {
24
+ return vec4(hash11(p+0.13), hash11(p+7.71), hash11(p+23.9), hash11(p+91.7));
25
+ }
26
+ ```
27
+
28
+ No buffer object, no numpy RNG array, no upload step. An SSBO earns its keep
29
+ only once something actually *writes* per-instance state over time (a
30
+ compute-shader velocity sim, or multiple independently-triggered effects each
31
+ needing their own origin) — not just to read constants once.
32
+
33
+ ## 2. One-shot triggering: a plain `triggerTime` uniform, not a looping `fract()`
34
+
35
+ For an effect that fires once, drive it from `osg_SimulationTime`
36
+ (auto-provided every frame) and a single `triggerTime` uniform, defaulted far
37
+ in the past so it's invisible until triggered:
38
+
39
+ ```glsl
40
+ uniform float osg_SimulationTime;
41
+ uniform float triggerTime = -1000.0;
42
+ uniform float duration = 1.2;
43
+
44
+ float t = (osg_SimulationTime - triggerTime) / duration; // NOT fract()'d
45
+ ```
46
+
47
+ An envelope like `smoothstep(0,0.08,t) * (1-smoothstep(0.5,1.0,t))` doubles
48
+ as both the grow/shrink curve and the "invisible before t=0 and after t=1"
49
+ gate — no separate visibility toggle needed.
50
+
51
+ ```python
52
+ def trigger(node, viewer):
53
+ node.stateSet.uniforms["triggerTime"] = float(viewer.frameStamp.simulationTime)
54
+ ```
55
+
56
+ Bind number keys to different nodes/presets (`osgGA.GUIEventHandler`,
57
+ `ea.type == osgGA.GUIEventAdapter.KEYDOWN`, `ea.key == ord("1")`) for
58
+ instant hands-on comparison of variants.
59
+
60
+ ## 3. Additive-blend saturation dominates the read of the effect — tune it first
61
+
62
+ With a few hundred instances overlapping in a small screen area under
63
+ `BlendFunc(GL_ONE, GL_ONE)`, even modest per-quad brightness sums to solid
64
+ white fast:
65
+
66
+ - Weight any secondary color-driving term (e.g. a height-based bias) low
67
+ relative to the primary noise term — `n * 1.0 + heightBias * 0.15`, not
68
+ `heightBias * 0.5`. Too high washes out noise detail into a flat blob.
69
+ - Multiply final alpha by a damping factor (~0.5–0.6) so one quad's
70
+ contribution stays below full brightness, leaving room for overlaps to sum
71
+ into mid-ramp colors instead of instantly clipping white.
72
+
73
+ ## 4. A Program hot-swap does NOT reset uniforms already bound on the StateSet
74
+
75
+ Extends [`15-shader-hotswap.md`](15-shader-hotswap.md). Swapping just the
76
+ `Program` (`setAttributeAndModes(newProgram, ON|OVERRIDE)`) leaves any
77
+ uniform already explicitly set from Python (`ss.uniforms["duration"] = 2.5`)
78
+ bound on the StateSet — it silently overrides the new shader's own
79
+ `uniform float duration = 1.2;` GLSL default. If a hot-swap changes intended
80
+ defaults, explicitly re-set every uniform that changed.
81
+
82
+ Reading a uniform's current value back out is `.value`, not `.getFloat()` or
83
+ index `[0]`: `fire.stateSet.uniforms["duration"].value`.
84
+
85
+ ## 5. Verifying a fast one-shot effect: force a known `t`, don't chase real-time screenshots
86
+
87
+ A `duration=1.2s` burst is easy to miss with `capture_framebuffer()` (see
88
+ [`01-core.md`](01-core.md) rule 6). Set `triggerTime` to a known offset in
89
+ the past instead of triggering "now" and hoping the capture lands mid-animation:
90
+
91
+ ```python
92
+ now = float(viewer.frameStamp.simulationTime)
93
+ node.stateSet.uniforms["triggerTime"] = now - 0.4 # force t ~= 0.4/duration, right now
94
+ ```
95
+
96
+ For a flat/ground-plane effect (a shockwave ring, anything not billboarded),
97
+ also consider the default trackball angle can leave it nearly edge-on and
98
+ hidden behind a brighter overlapping effect — a temporary top-down
99
+ `viewer.camera.viewMatrix = osg.Matrix.lookAt(...)` (gets overwritten by the
100
+ live `CameraManipulator` next frame, so capture immediately after) rules that
101
+ out before assuming the geometry is broken.
102
+
103
+ ## 6. `del viewer.eventHandlers[i]` can corrupt a LATER handler's Python identity
104
+
105
+ With `[DebugHandler, HandlerA, HandlerB]`, `del viewer.eventHandlers[1]`
106
+ leaves a surviving `HandlerB` that comes back from `__getitem__` as a bare
107
+ base-class `osgGA.GUIEventHandler` — Python-side attributes set in its own
108
+ `__init__` are gone, and its `handle()` override silently stops firing
109
+ (falls back to the C++ base no-op). Not yet root-caused.
110
+
111
+ **Workaround:** overwrite in place instead of delete —
112
+ `viewer.eventHandlers[i] = new_handler` preserves identity correctly. Avoid
113
+ `del`-ing a middle element of `eventHandlers` if anything after it needs to
114
+ keep working.
115
+
116
+ ## 7. Don't guard a MappingProxy assignment with an existence check first
117
+
118
+ `ss.uniforms[key] = value` already creates-or-updates on its own
119
+ (`UniformsTag::apply()` in `pyosg/osg/State.hpp`). Writing
120
+ `if key in ss.uniforms: ss.uniforms[key] = value` makes the assignment
121
+ *conditional on the uniform already existing* — silently no-ops a node's
122
+ very first trigger, since construction only sets the GLSL default, not a
123
+ real bound uniform, until something actually assigns to it. An unconditional
124
+ assignment through a `MappingProxy`/`ValueMappingProxy` is already the safe,
125
+ correct form; adding an existence check in front of it is closer to
126
+ introducing a bug than preventing one.
127
+
128
+ ## 8. Nested one-shot effects need a recursive `trigger()`, not a deeper positional walk
129
+
130
+ When a builder wraps several effect groups in per-position
131
+ `MatrixTransform`s — one level deeper than a flat 2–4 child layout a
132
+ `trigger(node, viewer)` originally assumed — recurse to leaf nodes instead of
133
+ adding a depth parameter:
134
+
135
+ ```python
136
+ def trigger(node, viewer):
137
+ now = float(viewer.frameStamp.simulationTime)
138
+
139
+ def walk(n):
140
+ if isinstance(n, osg.Group):
141
+ for child in n.children:
142
+ walk(child)
143
+ else:
144
+ n.stateSet.uniforms["triggerTime"] = now
145
+
146
+ walk(node)
147
+ ```
148
+
149
+ Strict superset of flat-layout behavior, and handles arbitrarily deeper
150
+ nesting for free.
151
+
152
+ ## 9. Multi-instance variety: randomize kwargs per instance, not shared state
153
+
154
+ When every tunable parameter is already "just a kwarg that flows straight to
155
+ a uniform," giving each instance independently `random.uniform()`-jittered
156
+ kwargs at construction time is close to free — prefer N independently
157
+ parameterized instances of an existing single-instance builder over one
158
+ shared preset placed N times, when the builder is already kwargs-shaped.
159
+
160
+ ## 10. Extending a deliberately-minimal ImGui "bin": add a knob only when needed
161
+
162
+ `osgx.imgui` is intentionally not a general Dear ImGui wrapper — a small,
163
+ fixed set of "knob" primitives (`slider_float`, `checkbox`, `radio_group`,
164
+ ...) each returning a `(changed, value)` tuple, since Python values aren't
165
+ mutable references the way ImGui's C++ `&value` out-params expect. Add a
166
+ primitive only when a concrete control is needed:
167
+
168
+ - `button(label) -> bool`
169
+ - `color_edit3(label, r, g, b) -> (changed, r, g, b)`
170
+
171
+ Both are one-line `ImGui::` wrappers in `~/dev/osgx/src/ImGui.cpp` +
172
+ `ext/python/osgx-imgui.cpp`, no new C++ classes. **C++-first, as always**
173
+ (see [[feedback_cpp_first_design]]): the primitive lives in `osgx::imgui`
174
+ proper, Python just calls it.
175
+
176
+ Two sliders in *different* panel sections sharing the exact same label
177
+ collide on the same ImGui ID — `Panel::draw()` only wraps a section in
178
+ `PushID` when `expand=True`, so a naive per-layer helper reusing the raw
179
+ uniform name as the label (`"duration"` for fire, shockwave, smoke, embers
180
+ alike) silently merges all four sliders' drag state. Fix: prefix every
181
+ control label with its section name ("Fire Duration", "Shockwave Duration", ...).
182
+
183
+ A slider that *reads* a uniform's `.value` needs that uniform to already
184
+ exist on the `StateSet` (unlike writing, reading is not create-or-update).
185
+ Fragment-only tuning knobs that were only ever given a GLSL default (never
186
+ assigned from Python) don't exist as a real uniform the first time a slider
187
+ section tries to read them. Fix: seed every slider's uniform to its known
188
+ default once, unconditionally, right before building the section — not a
189
+ guarded "if missing" check (same antipattern as point 7, on the read side).
@@ -0,0 +1,157 @@
1
+ # Deterministic captures of time-driven GPU effects
2
+
3
+ `capture_framebuffer()` makes the GL context current; it does **not** choose
4
+ which instant of an animation gets rendered. Triggering an effect and racing
5
+ a capture against the next `viewer.frame()` is inherently unreliable.
6
+
7
+ The fix is an effect-local clock with two modes: normal playback derives
8
+ elapsed time from `osg_SimulationTime - triggerTime`; inspection playback
9
+ supplies an explicit elapsed age in seconds.
10
+
11
+ ## Shader contract
12
+
13
+ Add a uniform whose negative value means "use realtime":
14
+
15
+ ```glsl
16
+ uniform float osg_SimulationTime;
17
+ uniform float triggerTime = -1000.0;
18
+ uniform float effectAge = -1.0;
19
+
20
+ float elapsed = effectAge >= 0.0 ? effectAge : osg_SimulationTime - triggerTime;
21
+ float t = clamp(elapsed / duration, 0.0, 1.0);
22
+ ```
23
+
24
+ Use `elapsed`, not `osg_SimulationTime`, for **every** time-varying part of
25
+ the effect that needs to match the captured instant: movement, noise scroll,
26
+ flicker, size envelopes, fragment breakup. Freezing only the normalized `t`
27
+ while a noise lookup still uses realtime leaves the result nondeterministic.
28
+
29
+ Seed the uniform from Python during construction:
30
+
31
+ ```python
32
+ ss.uniforms["effectAge"] = -1.0
33
+ ```
34
+
35
+ ## Trigger and scrub helpers
36
+
37
+ For a nested effect, recurse to drawable-owning leaves. Starting realtime
38
+ playback must explicitly clear a previously frozen age:
39
+
40
+ ```python
41
+ def trigger(node, viewer):
42
+ now = float(viewer.frameStamp.simulationTime)
43
+
44
+ def walk(n):
45
+ if isinstance(n, osg.Group):
46
+ for child in n.children:
47
+ walk(child)
48
+ else:
49
+ n.stateSet.uniforms["effectAge"] = -1.0
50
+ n.stateSet.uniforms["triggerTime"] = now
51
+
52
+ walk(node)
53
+
54
+ def set_age(node, effect_age):
55
+ """Freeze at elapsed seconds; pass a negative value to resume realtime."""
56
+
57
+ def walk(n):
58
+ if isinstance(n, osg.Group):
59
+ for child in n.children:
60
+ walk(child)
61
+ else:
62
+ n.stateSet.uniforms["effectAge"] = effect_age
63
+
64
+ walk(node)
65
+ ```
66
+
67
+ Age is measured in seconds rather than normalized phase, so layers with
68
+ different durations stay synchronized to one source event: at age `1.2`,
69
+ every layer evaluates itself 1.2 seconds after the trigger and applies its
70
+ own duration naturally.
71
+
72
+ ## Live capture workflow
73
+
74
+ In a tmux-backed `pyosg_repl.py` session, freeze the effect, then use the
75
+ controller's queued capture (not a top-level `readPixels()`):
76
+
77
+ ```python
78
+ set_age(effect, 1.2)
79
+ await _osg_repl_controller.capture_framebuffer("/tmp/effect-age-1.2.png")
80
+ ```
81
+
82
+ If a person may be at the same keyboard/mouse while this runs, wrap it in
83
+ `_osg_repl_controller.locked_input()` (`examples/pyosg_repl.py`'s
84
+ `AgentInputLock`) — otherwise a keypress that re-triggers or mutates the
85
+ effect can land on the same frame as `set_age()`/the capture, unfreezing the
86
+ very state being inspected:
87
+
88
+ ```python
89
+ with _osg_repl_controller.locked_input():
90
+ set_age(effect, 1.2)
91
+ await _osg_repl_controller.capture_framebuffer("/tmp/effect-age-1.2.png")
92
+ ```
93
+
94
+ To prove determinism rather than just eyeball a screenshot, capture the same
95
+ frozen frame twice after letting realtime frames advance in between, then
96
+ compare the files outside the REPL:
97
+
98
+ ```python
99
+ with _osg_repl_controller.locked_input():
100
+ set_age(effect, 1.2)
101
+ await _osg_repl_controller.capture_framebuffer("/tmp/effect-a.png")
102
+ await asyncio.sleep(0.5)
103
+ await _osg_repl_controller.capture_framebuffer("/tmp/effect-b.png")
104
+ ```
105
+
106
+ ```bash
107
+ sha256sum /tmp/effect-a.png /tmp/effect-b.png
108
+ cmp -s /tmp/effect-a.png /tmp/effect-b.png
109
+ ```
110
+
111
+ This proves only the frozen nodes are deterministic. To capture a whole
112
+ composite effect, every visible time-driven layer must implement the same
113
+ clock contract; hide or freeze unrelated animated scenery, UI, camera shake,
114
+ and particle layers before comparing capture bytes.
115
+
116
+ ## Short realtime video captures
117
+
118
+ For a short MP4 rather than a one-off PNG, use the model-controls facade's
119
+ callback-driven recorder:
120
+
121
+ ```python
122
+ _osg_repl_controls.capture.video("/tmp/take.mp4", fps=24, duration=5)
123
+ ```
124
+
125
+ It samples in the final-draw callback on a monotonic wall-clock schedule and
126
+ streams raw RGB frames directly to FFmpeg. Good for recording a user's live
127
+ camera manipulation because the call returns immediately and leaves input
128
+ unlocked by default. Pass `lock_input=True` when a script is posing the
129
+ camera or updating uniforms and a human must not race it:
130
+
131
+ ```python
132
+ _osg_repl_controls.capture.video(
133
+ "/tmp/deterministic-take.mp4",
134
+ fps=24,
135
+ duration=5,
136
+ lock_input=True,
137
+ )
138
+ ```
139
+
140
+ For a reproducible video, first set every time-driven visible effect to its
141
+ explicit age (and keep unrelated animation frozen) before starting the
142
+ recorder. The video sampler controls *which wall-clock moments are
143
+ captured*; it does not make shader time deterministic by itself.
144
+
145
+ Readback still uses synchronous `osg.Image.readPixels()` in the final-draw
146
+ callback — not yet a PBO/fence-backed asynchronous GPU transfer.
147
+
148
+ ## Realtime handoff
149
+
150
+ After inspecting an age, either resume the existing effect with
151
+ `set_age(effect, -1.0)` or restart it with `trigger(effect, viewer)`. Prefer
152
+ the latter when the effect may already have elapsed past its duration.
153
+
154
+ `examples/pyosg-praxis.py` is the current minimal working reference. Promote
155
+ this contract into `pyosg-fire.py` only as a coherent change across its
156
+ fire, shockwave, smoke, and ember shaders — a partial conversion gives a
157
+ misleading "frozen" composite whose remaining layers still race realtime.
@@ -0,0 +1,77 @@
1
+ # Object introspection and lifetime debugging
2
+
3
+ Every `osg::Object` subclass in these bindings exposes a small cluster of
4
+ introspection primitives — for "what is this node actually holding" and "did
5
+ this object actually get destroyed," not just serialization.
6
+
7
+ ## `.dumps()` — OSGT text serialization
8
+
9
+ ```python
10
+ print(node.dumps().decode())
11
+ ```
12
+
13
+ Verifies what attributes, textures, and state are actually attached to a
14
+ node/geometry without a separate viewer or tool. Works on live objects
15
+ mid-session.
16
+
17
+ ## `.addr` — the real C++ memory address
18
+
19
+ A `void*` to the actual object, not a vtable-offset pointer. Use to confirm
20
+ two Python handles refer to the *same* underlying C++ object (`a.addr ==
21
+ b.addr`) rather than two separate wrappers, or to correlate with addresses
22
+ printed by C++-side `osg::notify()` output.
23
+
24
+ ## `.referenceCount` — `RefCounts(cpp=N, py=M)`
25
+
26
+ The real OSG-side `ref_ptr` count and the Python-side refcount, separately.
27
+
28
+ **The `py` count is artificially inflated inside an interactive IPython
29
+ session** — `Out[]` history caching, `_`/`__`/`___`, and other REPL
30
+ bookkeeping can hold references you didn't intentionally keep. A high `py`
31
+ count does not by itself mean a leak. Removing an object from a scene graph
32
+ container only drops *one* `cpp` ref; if a Python variable (or `Out[]`
33
+ history) still points at it, the object stays fully alive.
34
+
35
+ ## `debug=True` / `debug=<callable>` — ground-truth destruction proof
36
+
37
+ Pass as a kwarg to an `osg.Object`-derived constructor to attach a lifetime
38
+ probe via the object's `UserDataContainer`:
39
+
40
+ ```python
41
+ node = osg.Node(debug=True) # notify()'s "Observing"/"Destroying"
42
+ node = osg.Node(debug=lambda addr, type_, name, deletions=deletions: ...) # custom callback
43
+ ```
44
+
45
+ **Only works for types wired into the binding layer's `kwargs_init` chain**
46
+ (see `pyosg/pyosg.hpp`'s manifest — `kwargs_base<T>` + each type's own
47
+ `kwargs_init_own`). Most `osg::Object` subclasses are, but it isn't automatic
48
+ just from deriving from `osg::Object` in C++ — a type hand-written with plain
49
+ `py::init<...>()` overloads silently rejects `debug=` (and `name=`,
50
+ `dataVariance=`) with a constructor-overload-mismatch error rather than a
51
+ clear "unsupported kwarg" message. If `debug=`/`name=` fails with
52
+ `incompatible constructor arguments`, check whether the type is in that
53
+ manifest before assuming the probe itself is broken.
54
+
55
+ Since the probe lives inside the target's own `UserDataContainer`, its
56
+ destructor fires at true C++ destruction — not "removed from the scene
57
+ graph," not "Python variable went out of scope." This is the ground-truth
58
+ tool for verifying an object is actually gone, as opposed to inferring it
59
+ from refcounts alone.
60
+
61
+ **The `debug=<callable>` callback is subject to the exact same
62
+ cross-context free-variable `NameError` as any other C++-invoked Python
63
+ callback** (see [`01-core.md`](01-core.md) rule 2) — bind anything it needs
64
+ as a default argument: `def on_delete(addr, type_, name, deletions=deletions):
65
+ ...`. This applies even though the trigger here is an ordinary Python
66
+ refcounting/GC destructor call on the main thread, not the render loop or any
67
+ background thread.
68
+
69
+ ## How to combine these when investigating a leak
70
+
71
+ 1. `.dumps()` to see what's really attached.
72
+ 2. `.addr` + `.referenceCount` together to distinguish "detached from the
73
+ scene graph" (a removed-but-still-Python-referenced object shows `cpp` >
74
+ 0) from "actually destroyed."
75
+ 3. `debug=` when you need proof, not inference — especially when the bug
76
+ might be in the binding layer itself (proxy/container implementation),
77
+ not application code.