unitytk 0.0.10__tar.gz → 0.0.12__tar.gz
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.
- {unitytk-0.0.10/unitytk.egg-info → unitytk-0.0.12}/PKG-INFO +1 -1
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_render_effects_e2e.py +13 -6
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_render_opacity_e2e.py +14 -13
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_shadow_plane_runtime_integration.py +115 -268
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_shared_material_standalone.py +26 -27
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/__init__.py +1 -1
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/RenderEffectsController.cs +1 -1
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/ShadowPlane.shader +35 -42
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/ShadowPlaneController.cs +57 -65
- unitytk-0.0.12/unitytk/templates/ShadowPlaneHorizon.hlsl +344 -0
- {unitytk-0.0.10 → unitytk-0.0.12/unitytk.egg-info}/PKG-INFO +1 -1
- unitytk-0.0.10/unitytk/templates/ShadowPlaneHorizon.hlsl +0 -471
- {unitytk-0.0.10 → unitytk-0.0.12}/LICENSE +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/MANIFEST.in +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/docs/README.md +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/pyproject.toml +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/setup.cfg +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_asset_delivery.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_audio_events_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_base_test.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_bool_to_fade_curve.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_clip_name_contract_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_controller_runtime_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_deploy_templates.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_emissive_groups_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_fade_window_preservation.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_keyed_visibility.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_launcher.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_lightmap_metadata_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_multi_section_audio_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_render_opacity_controller.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_render_opacity_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_run_tests.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_scene_builder.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_shadow_plane_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_shot_metadata_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_shots_audio_sidebyside_integration.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_standalone_enduser.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/test/test_temp_projects.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/asset_delivery.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/launcher.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/scene_builder.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/template_deployer.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/AudioEventController.cs +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/EmissiveGroupController.cs +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/EmissiveGroups.hlsl +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/EmissiveGroupsLit.shader +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/LightmapMetadataController.cs +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/M3trik.Unitytk.asmdef +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/README.md +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/ShotMetadataController.cs +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/UnitytkSettings.cs +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk/templates/__init__.py +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk.egg-info/SOURCES.txt +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk.egg-info/dependency_links.txt +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk.egg-info/requires.txt +0 -0
- {unitytk-0.0.10 → unitytk-0.0.12}/unitytk.egg-info/top_level.txt +0 -0
|
@@ -79,8 +79,12 @@ cube = cmds.polyCube(name="glow")[0]
|
|
|
79
79
|
|
|
80
80
|
# Opacity: a fade-in over 1..20 (mirrors visibility as before).
|
|
81
81
|
RenderEffects.key_fade([cube], start=1, end=20, direction="in")
|
|
82
|
-
# Highlight: 60-frame period, bright 50%, ramps 25% each
|
|
83
|
-
#
|
|
82
|
+
# Highlight: 60-frame period, bright 50%, ramps 25% each. The train is
|
|
83
|
+
# BRACKETED by dim keys at start and end (mayatk 2026-09-05: Maya holds a
|
|
84
|
+
# curve's first value back to the start of time, so a pulse that merely began
|
|
85
|
+
# bright glowed for the whole timeline before it), and the default lead-in is
|
|
86
|
+
# the cycle's own 15-frame ramp -> dim at 1, bright 16..31, dim 46..61,
|
|
87
|
+
# bright 76..91, back to dim at 120.
|
|
84
88
|
RenderEffects.key_pulse(
|
|
85
89
|
[cube], start=1, end=120, period=60, bright_fraction=0.5,
|
|
86
90
|
ramp_fraction=0.25, color=(0.2, 0.5, 1.0),
|
|
@@ -391,16 +395,19 @@ class TestRenderEffectsE2E(unittest.TestCase):
|
|
|
391
395
|
got = [float(c) for c in unity["highlight_colors"][0].split(",")]
|
|
392
396
|
for value, want in zip(got, (0.2, 0.5, 1.0)):
|
|
393
397
|
self.assertAlmostEqual(value, want, delta=1 / 255)
|
|
394
|
-
# (e) The pulse shape survived: bright holds 1, dim holds 0
|
|
398
|
+
# (e) The pulse shape survived: bright holds 1, dim holds 0, and the
|
|
399
|
+
# DIM BRACKET at `start` is there (mayatk 2026-09-05 -- the ramp opens
|
|
400
|
+
# at 0, not 1). Measured against the authored curve in mayapy for these
|
|
401
|
+
# exact fixture params: 1->0, 16->1, 31->1, 46->0, 61->0, 76->1, 91->1.
|
|
395
402
|
samples = {
|
|
396
403
|
int(s.split("|")[0]): float(s.split("|")[1])
|
|
397
404
|
for s in unity["highlight_samples"]
|
|
398
405
|
}
|
|
399
|
-
self.assertAlmostEqual(samples[1],
|
|
406
|
+
self.assertAlmostEqual(samples[1], 0.0, places=2)
|
|
400
407
|
self.assertAlmostEqual(samples[16], 1.0, places=2)
|
|
401
|
-
self.assertAlmostEqual(samples[31],
|
|
408
|
+
self.assertAlmostEqual(samples[31], 1.0, places=2)
|
|
402
409
|
self.assertAlmostEqual(samples[46], 0.0, places=2)
|
|
403
|
-
self.assertAlmostEqual(samples[61],
|
|
410
|
+
self.assertAlmostEqual(samples[61], 0.0, places=2)
|
|
404
411
|
# (f) Emission keyword on the highlighted material.
|
|
405
412
|
self.assertTrue(unity["emission_keyword"], "no material inspected")
|
|
406
413
|
self.assertTrue(
|
|
@@ -11,8 +11,8 @@ curve values, reimport idempotency).
|
|
|
11
11
|
Pipeline:
|
|
12
12
|
1. Maya (mayapy): build a cube + 'opacity' attr, key opacity in a
|
|
13
13
|
4-key fade-in/hold/fade-out pattern, call
|
|
14
|
-
:meth:`RenderOpacity.prepare_for_export` to
|
|
15
|
-
export FBX.
|
|
14
|
+
:meth:`RenderOpacity.prepare_for_export` to stage the curve proxy
|
|
15
|
+
(nothing keys visibility), export FBX.
|
|
16
16
|
2. Unity (batchmode): deploy ``UnitytkSettings.cs`` (the shared
|
|
17
17
|
ImportGate/CarrierImport home), ``RenderEffectsController.cs`` and
|
|
18
18
|
``AudioEventController.cs``. Import the FBX. Verify:
|
|
@@ -65,7 +65,7 @@ def _unity_available() -> bool:
|
|
|
65
65
|
# ======================================================================
|
|
66
66
|
|
|
67
67
|
MAYA_EXPORT_SCRIPT = r'''
|
|
68
|
-
"""Executed inside mayapy — keys opacity,
|
|
68
|
+
"""Executed inside mayapy — keys opacity, stages the curve proxy, exports FBX."""
|
|
69
69
|
import sys, os, json
|
|
70
70
|
|
|
71
71
|
# mayatk/pythontk repo roots arrive via PYTHONPATH set by the parent test.
|
|
@@ -97,11 +97,12 @@ cmds.setKeyframe(cube, attribute="opacity", time=140, value=0.0) # faded out
|
|
|
97
97
|
|
|
98
98
|
assert cmds.attributeQuery("opacity", node=str(cube), exists=True)
|
|
99
99
|
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
100
|
+
# Stage the curve-proxy transport: a fade_cube__opacity child carries the
|
|
101
|
+
# opacity curve on scale.x, which the importer rebinds to the controller
|
|
102
|
+
# (Unity flattens animated custom properties onto the root). Nothing keys
|
|
103
|
+
# visibility: presence is derived from the authored opacity downstream.
|
|
104
104
|
synced = RenderOpacity.prepare_for_export(objects=[cube])
|
|
105
|
+
proxies = cmds.ls("*__opacity") or []
|
|
105
106
|
|
|
106
107
|
vis_keys = cmds.keyframe(cube, attribute="visibility", q=True, tc=True) or []
|
|
107
108
|
opa_keys = cmds.keyframe(cube, attribute="opacity", q=True, tc=True) or []
|
|
@@ -127,6 +128,7 @@ results = {
|
|
|
127
128
|
"opacity_keys": opa_keys,
|
|
128
129
|
"visibility_keys": vis_keys,
|
|
129
130
|
"synced_objects": synced,
|
|
131
|
+
"proxies": proxies,
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
with open(os.path.join(output_dir, "maya_results.json"), "w") as f:
|
|
@@ -407,7 +409,7 @@ class TestRenderOpacityE2E(unittest.TestCase):
|
|
|
407
409
|
|
|
408
410
|
@MayaEnv.skip_unless_available()
|
|
409
411
|
def test_01_maya_export(self):
|
|
410
|
-
"""Author opacity,
|
|
412
|
+
"""Author opacity, stage the curve proxy via prepare_for_export, export FBX."""
|
|
411
413
|
script_path = os.path.join(self.temp_dir, "maya_export.py")
|
|
412
414
|
with open(script_path, "w", encoding="utf-8") as f:
|
|
413
415
|
f.write(MAYA_EXPORT_SCRIPT)
|
|
@@ -444,12 +446,11 @@ class TestRenderOpacityE2E(unittest.TestCase):
|
|
|
444
446
|
r = self.__class__.maya_results
|
|
445
447
|
self.__class__.fbx_path = r["fbx_path"]
|
|
446
448
|
self.assertTrue(r["fbx_exists"])
|
|
447
|
-
# prepare_for_export
|
|
448
|
-
self.
|
|
449
|
-
|
|
450
|
-
0,
|
|
451
|
-
"prepare_for_export should have synced visibility keys",
|
|
449
|
+
# prepare_for_export stages the curve proxy and keys no visibility
|
|
450
|
+
self.assertTrue(
|
|
451
|
+
r["proxies"], "prepare_for_export should have staged the opacity proxy"
|
|
452
452
|
)
|
|
453
|
+
self.assertEqual(r["visibility_keys"], [], "no visibility mirror is written")
|
|
453
454
|
# The opacity keys should match what we authored
|
|
454
455
|
opa_set = {int(round(t)) for t in r["opacity_keys"]}
|
|
455
456
|
self.assertEqual(opa_set, {1, 20, 100, 140})
|
|
@@ -8,7 +8,7 @@ Unity and no DCC:
|
|
|
8
8
|
|
|
9
9
|
1. Create a Unity project, deploy the real package (controller + shader)
|
|
10
10
|
and copy two generated fixtures into Assets: a stripe silhouette
|
|
11
|
-
(``Fixture_shadow.png``) and
|
|
11
|
+
(``Fixture_shadow.png``) and a baked height-field map
|
|
12
12
|
(``Fixture_horizon.png``: a grounded pole and a floating disc, baked
|
|
13
13
|
in numpy per the contract's encoding).
|
|
14
14
|
2. An editor script builds a unit-quad plane on ``m3trik/ShadowPlane`` with
|
|
@@ -124,46 +124,33 @@ class StripeSilhouette:
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
class HorizonFixture:
|
|
127
|
-
"""
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
local ``(0.5, 0.3)``, off-centre on purpose so a
|
|
131
|
-
a swapped frame vector moves its shadow and fails
|
|
132
|
-
the
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
run of set sub-bins, ``G = cot(hi)`` of the later runs (0 with one run);
|
|
143
|
-
``lo`` is the cap.
|
|
127
|
+
"""Two poles and a floating slab, baked by pythontk's own bake into the
|
|
128
|
+
height-field map the contract ships, and the oracle that samples it.
|
|
129
|
+
|
|
130
|
+
The first pole stands at local ``(0.5, 0.3)``, off-centre on purpose so a
|
|
131
|
+
flipped bearing axis or a swapped frame vector moves its shadow and fails
|
|
132
|
+
the pixel comparison; the second stands beside the first's light ray. The
|
|
133
|
+
slab hovers between two heights over open ground, so a ray under it must
|
|
134
|
+
pass and one through it must block -- the span a plain height field
|
|
135
|
+
cannot hold.
|
|
136
|
+
|
|
137
|
+
There was an analytic map here, and before it a numpy transcription of
|
|
138
|
+
the HLSL; both pinned the shader to something other than the one oracle
|
|
139
|
+
every consumer answers to. The map is now the real bake, and ``alpha`` is
|
|
140
|
+
``HeightFieldMap.alpha`` decoding the very bytes the PNG holds (the map's
|
|
141
|
+
PNG round trip is exact).
|
|
144
142
|
"""
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
R_MAX = 3.0
|
|
153
|
-
#: The cot scale the map is BAKED with — deliberately NOT the record's
|
|
154
|
-
#: top-level ``max_stretch`` (the plane's live, keyable placement cap,
|
|
155
|
-
#: which an artist can retune after a bake). Decoding with the wrong one
|
|
156
|
-
#: scales every shadow length by the ratio, so the two differing here is
|
|
157
|
-
#: what makes the pixel comparison able to tell them apart.
|
|
158
|
-
MAX_STRETCH = 4.0
|
|
159
|
-
#: Grounded poles: (base (x, z), radius, height). The first is the one
|
|
160
|
-
#: the centroid check isolates; the second sits beside the first's light
|
|
161
|
-
#: ray, so from texels behind it the first pole holds a lower sub-bin of
|
|
162
|
-
#: the same bin with a gap between — two runs.
|
|
144
|
+
SIZE = 64
|
|
145
|
+
SPANS = 2
|
|
146
|
+
#: One fixed footprint for every variant (a pole alone, no slab), so the
|
|
147
|
+
#: texture layout and the frame never change between them.
|
|
148
|
+
BOUNDS = (-0.5, 2.5, -0.5, 2.0)
|
|
149
|
+
#: Grounded poles: (base (x, z), half-width, height) -- square posts.
|
|
163
150
|
POLES = (((0.5, 0.3), 0.06, 1.2), ((0.214, 1.028), 0.05, 0.8))
|
|
164
151
|
POLE = POLES[0][0]
|
|
165
|
-
#: The floating
|
|
166
|
-
#: placed so its shadow clears the first pole's shadow line.
|
|
152
|
+
#: The floating slab: centre (x, z), half-width, underside and top heights
|
|
153
|
+
#: -- placed so its shadow clears the first pole's shadow line.
|
|
167
154
|
DISC = (1.5, 0.9)
|
|
168
155
|
DISC_RADIUS = 0.5
|
|
169
156
|
DISC_LO = 0.7
|
|
@@ -172,178 +159,73 @@ class HorizonFixture:
|
|
|
172
159
|
FRAME_A = (1.0, 0.0, 0.0)
|
|
173
160
|
FRAME_B = (0.0, 0.0, 1.0)
|
|
174
161
|
|
|
175
|
-
@
|
|
176
|
-
def
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
s1 = s0 + sub
|
|
194
|
-
hit = np.zeros(phi_lo.shape, dtype=bool)
|
|
195
|
-
for shift in (-TWO_PI, 0.0, TWO_PI):
|
|
196
|
-
hit |= (
|
|
197
|
-
np.minimum(s1, phi_hi + shift) - np.maximum(s0, phi_lo + shift)
|
|
198
|
-
) > 1e-12
|
|
199
|
-
mask |= np.where(hit, 1 << j, 0)
|
|
200
|
-
return mask
|
|
201
|
-
|
|
202
|
-
@classmethod
|
|
203
|
-
def _first_bit(cls, mask):
|
|
204
|
-
"""Lowest set bit per texel (-1 when empty)."""
|
|
205
|
-
out = np.full(mask.shape, -1, dtype=int)
|
|
206
|
-
for j in range(cls.SUB_BINS - 1, -1, -1):
|
|
207
|
-
out = np.where(((mask >> j) & 1) == 1, j, out)
|
|
208
|
-
return out
|
|
209
|
-
|
|
210
|
-
@classmethod
|
|
211
|
-
def _run_at(cls, mask, bit):
|
|
212
|
-
"""Number of 0->1 transitions in ``mask`` up to ``bit`` (inclusive) —
|
|
213
|
-
the run index of that bit (0 where ``bit`` < 0)."""
|
|
214
|
-
count = np.zeros(mask.shape, dtype=int)
|
|
215
|
-
prev = np.zeros(mask.shape, dtype=int)
|
|
216
|
-
for j in range(cls.SUB_BINS):
|
|
217
|
-
b = (mask >> j) & 1
|
|
218
|
-
count += ((b == 1) & (prev == 0) & (j <= bit)).astype(int)
|
|
219
|
-
prev = b
|
|
220
|
-
return count
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _box(x0, x1, y0, y1, z0, z1):
|
|
164
|
+
pts = np.array(
|
|
165
|
+
[[x, y, z] for x in (x0, x1) for y in (y0, y1) for z in (z0, z1)],
|
|
166
|
+
dtype=float,
|
|
167
|
+
)
|
|
168
|
+
quads = [
|
|
169
|
+
(0, 1, 3, 2),
|
|
170
|
+
(4, 6, 7, 5),
|
|
171
|
+
(0, 4, 5, 1),
|
|
172
|
+
(2, 3, 7, 6),
|
|
173
|
+
(0, 2, 6, 4),
|
|
174
|
+
(1, 5, 7, 3),
|
|
175
|
+
]
|
|
176
|
+
tris = []
|
|
177
|
+
for a, b, c, d in quads:
|
|
178
|
+
tris += [(a, b, c), (a, c, d)]
|
|
179
|
+
return pts, np.array(tris, dtype=np.int64)
|
|
221
180
|
|
|
222
181
|
@classmethod
|
|
223
|
-
def
|
|
224
|
-
"""The map as an ``(rows*TILE_H, cols*TILE_W, 4)`` uint8 array,
|
|
225
|
-
row 0 at the top (PNG order). ``poles`` overrides the grounded set
|
|
226
|
-
and ``disc=False`` omits the floating layer (the centroid check
|
|
227
|
-
bakes the first pole alone)."""
|
|
182
|
+
def meshes(cls, poles=None, disc=True):
|
|
228
183
|
poles = cls.POLES if poles is None else poles
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
pz = r[:, None] * np.sin(theta)[None, :] # along frame b
|
|
238
|
-
|
|
239
|
-
def q(a):
|
|
240
|
-
"""Quantise a [0, 1] channel the way the contract's encoding does."""
|
|
241
|
-
return np.clip(np.round(np.clip(a, 0.0, 1.0) * 255.0), 0, 255).astype(
|
|
242
|
-
np.uint8
|
|
184
|
+
parts = [
|
|
185
|
+
cls._box(bx - r, bx + r, 0.0, h, bz - r, bz + r) for (bx, bz), r, h in poles
|
|
186
|
+
]
|
|
187
|
+
if disc:
|
|
188
|
+
cx, cz = cls.DISC
|
|
189
|
+
r = cls.DISC_RADIUS
|
|
190
|
+
parts.append(
|
|
191
|
+
cls._box(cx - r, cx + r, cls.DISC_LO, cls.DISC_HI, cz - r, cz + r)
|
|
243
192
|
)
|
|
193
|
+
return parts
|
|
244
194
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
per_pole = []
|
|
255
|
-
for base, rad, height in poles:
|
|
256
|
-
dx, dz = base[0] - px, base[1] - pz
|
|
257
|
-
d = np.hypot(dx, dz)
|
|
258
|
-
delta = np.arcsin(np.minimum(1.0, rad / np.maximum(d, 1e-9)))
|
|
259
|
-
phi_c = np.mod(np.arctan2(dz, dx), TWO_PI)
|
|
260
|
-
cot_hi = np.maximum(d - rad, 1e-3) / height # cot(atan(H / (d - rad)))
|
|
261
|
-
per_pole.append((phi_c - delta, phi_c + delta, cot_hi))
|
|
262
|
-
for k in range(cls.BINS):
|
|
263
|
-
masks = [cls._mask(lo, hi, k, step) for lo, hi, _ in per_pole]
|
|
264
|
-
union = np.zeros(px.shape, dtype=np.int64)
|
|
265
|
-
for m in masks:
|
|
266
|
-
union |= m
|
|
267
|
-
first = np.full(px.shape, np.inf)
|
|
268
|
-
later = np.full(px.shape, np.inf)
|
|
269
|
-
for m, (_, _, cot_hi) in zip(masks, per_pole):
|
|
270
|
-
run = cls._run_at(union, cls._first_bit(m))
|
|
271
|
-
first = np.where(
|
|
272
|
-
(m != 0) & (run == 1), np.minimum(first, cot_hi), first
|
|
273
|
-
)
|
|
274
|
-
later = np.where(
|
|
275
|
-
(m != 0) & (run >= 2), np.minimum(later, cot_hi), later
|
|
276
|
-
)
|
|
277
|
-
tile = np.zeros((cls.TILE_H, cls.TILE_W, 4), dtype=np.uint8)
|
|
278
|
-
hit = union != 0
|
|
279
|
-
tile[..., 0] = np.where(
|
|
280
|
-
hit & np.isfinite(first),
|
|
281
|
-
q(np.where(np.isfinite(first), first, 0) / ms),
|
|
282
|
-
0,
|
|
283
|
-
)
|
|
284
|
-
tile[..., 1] = np.where(
|
|
285
|
-
hit & np.isfinite(later),
|
|
286
|
-
q(np.where(np.isfinite(later), later, 0) / ms),
|
|
287
|
-
0,
|
|
288
|
-
)
|
|
289
|
-
tile[..., 2] = (union & 0xFF).astype(np.uint8)
|
|
290
|
-
tile[..., 3] = ((union >> 8) & 0xFF).astype(np.uint8)
|
|
291
|
-
place(0, k, tile)
|
|
292
|
-
|
|
293
|
-
# ---- floating: the disc ----
|
|
294
|
-
if not disc:
|
|
295
|
-
return img
|
|
296
|
-
dx, dz = cls.DISC[0] - px, cls.DISC[1] - pz
|
|
297
|
-
d = np.hypot(dx, dz)
|
|
298
|
-
inside = d < cls.DISC_RADIUS
|
|
299
|
-
delta = np.where(
|
|
300
|
-
inside,
|
|
301
|
-
math.pi,
|
|
302
|
-
np.arcsin(np.minimum(1.0, cls.DISC_RADIUS / np.maximum(d, 1e-9))),
|
|
303
|
-
)
|
|
304
|
-
phi_c = np.mod(np.arctan2(dz, dx), TWO_PI)
|
|
305
|
-
cot_lo = (d + cls.DISC_RADIUS) / cls.DISC_LO
|
|
306
|
-
cot_hi = np.where(
|
|
307
|
-
inside, 0.0, np.maximum(d - cls.DISC_RADIUS, 0.0) / cls.DISC_HI
|
|
195
|
+
@classmethod
|
|
196
|
+
def map(cls, poles=None, disc=True) -> "ptk.HeightFieldMap":
|
|
197
|
+
"""The baked map -- the reference itself."""
|
|
198
|
+
return ptk.ShadowHorizon.bake(
|
|
199
|
+
cls.meshes(poles, disc),
|
|
200
|
+
up=1,
|
|
201
|
+
size=cls.SIZE,
|
|
202
|
+
spans=cls.SPANS,
|
|
203
|
+
bounds=cls.BOUNDS,
|
|
308
204
|
)
|
|
309
|
-
for k in range(cls.BINS):
|
|
310
|
-
mask = cls._mask(phi_c - delta, phi_c + delta, k, step)
|
|
311
|
-
hit = mask != 0
|
|
312
|
-
tile = np.zeros((cls.TILE_H, cls.TILE_W, 4), dtype=np.uint8)
|
|
313
|
-
tile[..., 0] = np.where(hit, q(cot_lo / ms), 0)
|
|
314
|
-
tile[..., 1] = np.where(hit, q(cot_hi / ms), 0)
|
|
315
|
-
tile[..., 2] = (mask & 0xFF).astype(np.uint8)
|
|
316
|
-
tile[..., 3] = ((mask >> 8) & 0xFF).astype(np.uint8)
|
|
317
|
-
place(1, k, tile)
|
|
318
|
-
return img
|
|
319
205
|
|
|
320
|
-
# -------------------------------------------------------------- oracle
|
|
321
206
|
@classmethod
|
|
322
|
-
def
|
|
323
|
-
"""The
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
shader to a copy of itself: a divergence between them was invisible by
|
|
327
|
-
construction, and the divergences the doc of record claimed were
|
|
328
|
-
pinned were exactly the ones neither side could see. The map now goes
|
|
329
|
-
straight to `HorizonMap.alpha`, which the shared shader body is
|
|
330
|
-
written against, so the Unity pixels are compared to the one oracle
|
|
331
|
-
every other consumer answers to.
|
|
332
|
-
"""
|
|
333
|
-
return ptk.HorizonMap.from_rgba(
|
|
334
|
-
cls.bake() if img is None else img,
|
|
335
|
-
bins=cls.BINS,
|
|
336
|
-
size=(cls.TILE_W, cls.TILE_H),
|
|
337
|
-
r_min=cls.R_MIN,
|
|
338
|
-
r_max=cls.R_MAX,
|
|
339
|
-
max_stretch=cls.MAX_STRETCH,
|
|
340
|
-
)
|
|
207
|
+
def bake(cls, poles=None, disc=True) -> np.ndarray:
|
|
208
|
+
"""The map as the ``(S, S x (K + 2), 4)`` uint8 image the contract
|
|
209
|
+
ships, row 0 at the top (PNG order)."""
|
|
210
|
+
return cls.map(poles, disc).to_rgba()
|
|
341
211
|
|
|
342
212
|
@classmethod
|
|
343
|
-
def alpha(cls,
|
|
344
|
-
"""
|
|
345
|
-
positional
|
|
346
|
-
|
|
213
|
+
def alpha(cls, points, source, source_size=0.0, *, poles=None, disc=True):
|
|
214
|
+
"""``HeightFieldMap.alpha`` at contact-local ground ``points`` (N, 3)
|
|
215
|
+
for a positional ``source`` (contact-local) of diameter
|
|
216
|
+
``source_size``, decoding the image ``bake`` writes for the same
|
|
217
|
+
variant."""
|
|
218
|
+
hmap = cls.map(poles, disc)
|
|
219
|
+
back = ptk.HeightFieldMap.from_rgba(
|
|
220
|
+
hmap.to_rgba(),
|
|
221
|
+
size=cls.SIZE,
|
|
222
|
+
spans=cls.SPANS,
|
|
223
|
+
bounds=cls.BOUNDS,
|
|
224
|
+
ground=0.0,
|
|
225
|
+
up=1,
|
|
226
|
+
height_scale=hmap.height_scale,
|
|
227
|
+
)
|
|
228
|
+
return back.alpha(points, light=source, source_size=source_size)
|
|
347
229
|
|
|
348
230
|
|
|
349
231
|
def _write_png(path: str, img: np.ndarray) -> None:
|
|
@@ -613,13 +495,13 @@ __CASES__
|
|
|
613
495
|
// (c3) horizon: a sizeless source, then a finite one.
|
|
614
496
|
ctrl.rigType = "horizon";
|
|
615
497
|
ctrl.horizonMap = horizon;
|
|
616
|
-
ctrl.
|
|
617
|
-
ctrl.
|
|
618
|
-
ctrl.
|
|
619
|
-
ctrl.
|
|
620
|
-
ctrl.
|
|
621
|
-
ctrl.
|
|
622
|
-
ctrl.
|
|
498
|
+
ctrl.horizonSize = __SIZE__;
|
|
499
|
+
ctrl.horizonSpans = __SPANS__;
|
|
500
|
+
ctrl.horizonLevels = __LEVELS__;
|
|
501
|
+
ctrl.horizonBounds = new Vector4(__BND0__f, __BND1__f, __BND2__f, __BND3__f);
|
|
502
|
+
ctrl.horizonHeightScale = __HSCALE__f;
|
|
503
|
+
ctrl.horizonMapping = "heightfield";
|
|
504
|
+
ctrl.horizonEncoding = 2;
|
|
623
505
|
ctrl.horizonFrameA = new Vector3(__A0__f, __A1__f, __A2__f);
|
|
624
506
|
ctrl.horizonFrameB = new Vector3(__B0__f, __B1__f, __B2__f);
|
|
625
507
|
ctrl.horizonRect = new Vector4(1f, 1f, 0f, 0f);
|
|
@@ -634,16 +516,8 @@ __CASES__
|
|
|
634
516
|
sb.AppendLine(" \"block_axis_b\": " + V4(mpb.GetVector("_AxisB")) + ",");
|
|
635
517
|
sb.AppendLine(" \"block_axis_up\": " + V4(mpb.GetVector("_AxisUp")) + ",");
|
|
636
518
|
sb.AppendLine(" \"block_horizon_params\": " + V4(mpb.GetVector("_HorizonParams")) + ",");
|
|
637
|
-
sb.AppendLine(" \"
|
|
638
|
-
sb.AppendLine(" \"
|
|
639
|
-
// A map baked before the field existed carries no scale (0): the
|
|
640
|
-
// decode must fall back to the plane's live cap, not to zero.
|
|
641
|
-
ctrl.horizonMaxStretch = 0f;
|
|
642
|
-
ctrl.ApplyNow();
|
|
643
|
-
rend.GetPropertyBlock(mpb);
|
|
644
|
-
sb.AppendLine(" \"block_max_stretch_absent\": " + F(mpb.GetFloat("_MaxStretch")) + ",");
|
|
645
|
-
ctrl.horizonMaxStretch = __H_MAX_STRETCH__f;
|
|
646
|
-
ctrl.ApplyNow();
|
|
519
|
+
sb.AppendLine(" \"block_horizon_bounds\": " + V4(mpb.GetVector("_HorizonBounds")) + ",");
|
|
520
|
+
sb.AppendLine(" \"block_horizon_meta\": " + V4(mpb.GetVector("_HorizonMeta")) + ",");
|
|
647
521
|
sb.AppendLine(" \"block_horizon_map_overridden\": "
|
|
648
522
|
+ B(mpb.GetTexture("_HorizonMap") != null) + ",");
|
|
649
523
|
sb.AppendLine(" \"block_base_map_overridden\": "
|
|
@@ -713,15 +587,14 @@ def _verifier_source(results_path: str) -> str:
|
|
|
713
587
|
"__HX__": repr(HORIZON_SOURCE[0]),
|
|
714
588
|
"__HY__": repr(HORIZON_SOURCE[1]),
|
|
715
589
|
"__HZ__": repr(HORIZON_SOURCE[2]),
|
|
716
|
-
"
|
|
717
|
-
"
|
|
718
|
-
"
|
|
719
|
-
"
|
|
720
|
-
"
|
|
721
|
-
"
|
|
722
|
-
"
|
|
723
|
-
"
|
|
724
|
-
"__H_MAX_STRETCH__": repr(hz.MAX_STRETCH),
|
|
590
|
+
"__SIZE__": str(hz.SIZE),
|
|
591
|
+
"__SPANS__": str(hz.SPANS),
|
|
592
|
+
"__LEVELS__": str(hz.map().levels),
|
|
593
|
+
"__BND0__": repr(hz.BOUNDS[0]),
|
|
594
|
+
"__BND1__": repr(hz.BOUNDS[1]),
|
|
595
|
+
"__BND2__": repr(hz.BOUNDS[2]),
|
|
596
|
+
"__BND3__": repr(hz.BOUNDS[3]),
|
|
597
|
+
"__HSCALE__": repr(float(hz.map().height_scale)),
|
|
725
598
|
"__A0__": repr(hz.FRAME_A[0]),
|
|
726
599
|
"__A1__": repr(hz.FRAME_A[1]),
|
|
727
600
|
"__A2__": repr(hz.FRAME_A[2]),
|
|
@@ -989,7 +862,7 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
|
|
|
989
862
|
hz = HorizonFixture
|
|
990
863
|
self.assertEqual(
|
|
991
864
|
(tex["width"], tex["height"]),
|
|
992
|
-
(hz.
|
|
865
|
+
(hz.SIZE * (hz.SPANS + 2), hz.SIZE),
|
|
993
866
|
"the NPOT tile grid must keep its exact size",
|
|
994
867
|
)
|
|
995
868
|
self.assertEqual(tex["mip_count"], 1)
|
|
@@ -1006,39 +879,17 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
|
|
|
1006
879
|
self.assertAlmostEqual(unity["block_mode_horizon"], 1.0, places=5)
|
|
1007
880
|
params = unity["block_horizon_params"]
|
|
1008
881
|
self.assertEqual(
|
|
1009
|
-
[int(v) for v in params], [hz.
|
|
1010
|
-
)
|
|
1011
|
-
# (r_min, r_max) arrive in SCENE units -- the shared body takes no
|
|
1012
|
-
# unit slot, so the controller scales them; layers and the ground
|
|
1013
|
-
# plane's height in the frame take the other two.
|
|
1014
|
-
rng = unity["block_horizon_range"]
|
|
1015
|
-
self.assertAlmostEqual(rng[0], hz.R_MIN, places=5)
|
|
1016
|
-
self.assertAlmostEqual(rng[1], hz.R_MAX, places=5)
|
|
1017
|
-
self.assertEqual(int(rng[2]), hz.LAYERS)
|
|
1018
|
-
self.assertAlmostEqual(rng[3], RECORD["ground"], places=5)
|
|
1019
|
-
# The cotangent decode takes the scale the MAP was baked with, never
|
|
1020
|
-
# the plane's live placement cap (they differ in this fixture).
|
|
1021
|
-
self.assertAlmostEqual(
|
|
1022
|
-
unity["block_max_stretch"],
|
|
1023
|
-
hz.MAX_STRETCH,
|
|
1024
|
-
places=5,
|
|
1025
|
-
msg="the shader decodes with the wrong cot scale — a "
|
|
1026
|
-
"retuned maxStretch would rescale every shadow length",
|
|
1027
|
-
)
|
|
1028
|
-
self.assertNotAlmostEqual(
|
|
1029
|
-
hz.MAX_STRETCH,
|
|
1030
|
-
RECORD["max_stretch"],
|
|
1031
|
-
places=5,
|
|
1032
|
-
msg="the fixture can no longer tell the two apart",
|
|
1033
|
-
)
|
|
1034
|
-
# ...and falls back to the live cap only when the record carried none.
|
|
1035
|
-
self.assertAlmostEqual(
|
|
1036
|
-
unity["block_max_stretch_absent"],
|
|
1037
|
-
RECORD["max_stretch"],
|
|
1038
|
-
places=5,
|
|
1039
|
-
msg="a map with no baked scale must fall back to the "
|
|
1040
|
-
"plane's cap, not to zero",
|
|
882
|
+
[int(v) for v in params], [hz.SIZE, hz.SPANS, hz.map().levels, 0]
|
|
1041
883
|
)
|
|
884
|
+
# The footprint and the height scale arrive in SCENE units -- the
|
|
885
|
+
# shared body takes no unit slot, so the controller scales them; the
|
|
886
|
+
# ground plane's height in the frame rides in the meta vector.
|
|
887
|
+
bounds = unity["block_horizon_bounds"]
|
|
888
|
+
for got, want in zip(bounds, hz.BOUNDS):
|
|
889
|
+
self.assertAlmostEqual(got, want, places=5)
|
|
890
|
+
meta = unity["block_horizon_meta"]
|
|
891
|
+
self.assertAlmostEqual(meta[0], hz.map().height_scale, places=5)
|
|
892
|
+
self.assertAlmostEqual(meta[1], RECORD["ground"], places=5)
|
|
1042
893
|
# The frame travels as an origin and three WORLD axes; the source
|
|
1043
894
|
# travels in world space too. The fixture's contact is the identity,
|
|
1044
895
|
# so each is its own contact-local value.
|
|
@@ -1109,19 +960,18 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
|
|
|
1109
960
|
|
|
1110
961
|
@EnvSkip.unless(_unity_available(), "no Unity editor found")
|
|
1111
962
|
def test_05_horizon_pixels_match_oracle(self):
|
|
1112
|
-
"""Shader alpha == the contract's math on the same PNG (
|
|
1113
|
-
|
|
1114
|
-
|
|
963
|
+
"""Shader alpha == the contract's math on the same PNG (a sizeless
|
|
964
|
+
and a finite source), and the pole's shadow lies on the far side of
|
|
965
|
+
the light."""
|
|
1115
966
|
unity = self._pixels_or_skip()
|
|
1116
967
|
self.assertLessEqual(unity["h1_max_rg"], 2, "not a grey shadow (error shader?)")
|
|
1117
|
-
img = self.__class__.horizon_img
|
|
1118
968
|
xs, zs = _pixel_grid(unity)
|
|
1119
969
|
points = np.column_stack([xs, np.zeros_like(xs), zs])
|
|
1120
970
|
|
|
1121
971
|
for key, size in (("h1", 0.0), ("h2", HORIZON_SOURCE_SIZE)):
|
|
1122
972
|
got = _alpha(unity, f"{key}_r")
|
|
1123
973
|
inside, _ = _inside_plane(unity[f"{key}_placement"], xs, zs)
|
|
1124
|
-
expected = HorizonFixture.alpha(
|
|
974
|
+
expected = HorizonFixture.alpha(points, HORIZON_SOURCE, size)
|
|
1125
975
|
expected = np.where(inside, expected, 0.0)
|
|
1126
976
|
n = int(inside.sum())
|
|
1127
977
|
self.assertGreater(n, 200, f"{key}: the plane must cover pixels")
|
|
@@ -1164,12 +1014,9 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
|
|
|
1164
1014
|
# minus a pole-1-only bake leaves the disc's and the second pole's
|
|
1165
1015
|
# shadows, which are masked out of the centroid.
|
|
1166
1016
|
only1 = HorizonFixture.alpha(
|
|
1167
|
-
|
|
1168
|
-
points,
|
|
1169
|
-
HORIZON_SOURCE,
|
|
1170
|
-
0.0,
|
|
1017
|
+
points, HORIZON_SOURCE, 0.0, poles=HorizonFixture.POLES[:1], disc=False
|
|
1171
1018
|
)
|
|
1172
|
-
full = HorizonFixture.alpha(
|
|
1019
|
+
full = HorizonFixture.alpha(points, HORIZON_SOURCE, 0.0)
|
|
1173
1020
|
inside1, _ = _inside_plane(unity["h1_placement"], xs, zs)
|
|
1174
1021
|
w = np.where(inside1 & (np.abs(full - only1) < 0.01), got, 0.0)
|
|
1175
1022
|
self.assertGreater(float(w.sum()), 1.0, "no pole shadow to locate")
|