Repository navigation
Expand file tree
/
Copy pathspot_slam.py
More file actions
2038 lines (1881 loc) · 113 KB
/
Copy pathspot_slam.py
File metadata and controls
2038 lines (1881 loc) · 113 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Spot SLAM — procedural terrain + trees + depth camera + live map reconstruction.
Spot walks on TerrainGenerator terrain while a body-mounted forward depth camera
accumulates a 3-D point cloud. Every ~3 s a background thread runs marching cubes
to reconstruct the growing SLAM surface (semi-transparent blue) over the ground truth.
python spot_slam.py
python spot_slam.py --seed 7 --amplitude 0.20
python spot_slam.py --shot out.png
python spot_slam.py --film recovery.mp4 # headless scripted fall-recovery film (--film-dry checks it first)
python spot_slam.py --model idun_runs/spot_unified2/unifiedB_s0/spot_steps.pt # one policy walks and jumps: J, while walking
Controls: W/S = fwd/back A/D = strafe Q/E = turn | R = reset | mouse = orbit/zoom
The interactive window opens FULLSCREEN; pass --windowed for a 1200x720 window instead.
"""
import argparse, math, os, sys, threading, time
import numpy as np
import torch
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(os.path.dirname(_HERE)))
sys.path.insert(0, _HERE)
sys.path.insert(0, os.path.join(_HERE, "scratch_distillation")) # scratch_clock / scratch_env
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import threepp as tp
from threepp.rl import load_policy
from threepp.utils import fetch_file
from spot_deploy import (build_spot, fetch_assets,
_quat_to_R, _quat_from_R,
default_q, isaac_to_add, add_to_isaac, ACTION_SCALE, Z0, LIM, MASS)
from spot_depth_scan import ForwardDepthScanner
from spot_terrain_env import VX_HI, VY_HI, WZ_HI
from scratch_env import STIFF_GAINS
from scratch_clock import GAIT_PERIOD
from _common import v2_obs
from spot_feet import cpu_foot_tips
# Fall recovery (--recovery-model, spot_recovery_env.py): the walking policy hands over when the body tips past
# RECOVER_UP and takes control back once the robot has stood (up_z > 0.9, base 0.40 m above the ground) and been still
# for RECOVER_SETTLE control ticks.
# Pushes are sized as a velocity change of the whole robot (impulse = total mass x dv), in a random sideways direction
# jittered PUSH_JITTER_DEG. F = SHOVE: SHOVE_DV through the base's centre of mass; training shoves top out at 2 m/s, so the
# walking policy should usually ride it out. G = TRIP: TRIP_DV at TRIP_HEIGHT above the base's centre of mass (the top of
# the body), the smallest push that tips a standing Spot over reliably (--trip-probe); --knock-at uses the trip. The old F
# was 150 N·s 0.3 m above the base, ~5 m/s on 28 kg: "it's like a tornado" (Lars, 2026-09-14).
RECOVER_UP = 0.5
RECOVER_SETTLE = 25 # 0.5 s at 50 Hz
SHOVE_DV = 2.0 # m/s
# Measured 2026-09-14 with --trip-probe (shipped walking policy standing, the plant --recovery-model builds, 10 trials per
# step, +-10 deg jitter), at 0.095 m above the base's centre of mass: flat ground (--amplitude 0) tipped past up_z 0.5 in
# 0/10 at 1 m/s, 6/10 at 1.5, 10/10 at 2; the default spawn terrain in 0/10 at 1, 1.5 and 2 m/s (min up_z 0.62-0.78) and
# 10/10 at 2.5. So 2.5 m/s (70 N·s on 28 kg) is the smallest reliable trip on both, rounded up to 2.75. The 2 m/s shove
# through the centre of mass tipped 0/10 on either (min up_z 0.78-0.92).
TRIP_DV, TRIP_HEIGHT = 2.75, 0.095 # m/s, m
PUSH_JITTER_DEG = 10.0
TRIP_LADDER = (1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0) # --trip-probe dv steps, m/s
# SPOT_SCANSTATS=1 prints the 45-cell scan's spread every 60 frames — the check that the
# height scan the policy consumes is actually tracking terrain, independent of how it is drawn.
_SCANSTATS = bool(os.environ.get("SPOT_SCANSTATS"))
_RESET_AT = int(os.environ.get("SPOT_BENCH_RESET_AT", "0")) # bench-only reset smoke test
# ── constants ──────────────────────────────────────────────────────────────────
WORLD_SZ = 80.0
AMPLITUDE = 10.8
TREE_COUNT = 60
CLEAR_R = 7.0 # no trees within this radius of spawn
# Species mix for scatter_trees: (preset, name, weight, needs_frond, translucency, translucency_color)
# preset indices match apply_tree_preset: 0=Oak, 1=Pine/Spruce (whorl conifer), 2=Birch, 3=Willow.
TREE_SPECIES = [
(0, "oak", 0.30, False, 0.40, (0.55, 0.85, 0.30)),
(1, "spruce", 0.35, True, 0.45, (0.50, 0.80, 0.28)),
(2, "birch", 0.20, False, 0.42, (0.60, 0.85, 0.35)),
(3, "willow", 0.15, False, 0.40, (0.55, 0.85, 0.32)),
]
VARIANTS_PER_SPECIES = 5 # a handful of distinct seeds per species (like fjord/forest_demo);
# placements reuse these prototypes so the pool stays cheap
WATER_BIAS_R = 14.0 # within this radius of the pond centre, favour willows
SENSOR_W = 128 # 128x96 = 12.3k beams: ~12.5 cm footprint at far range, still
SENSOR_H = 96 # denser than the 15 cm elevation-map cell — was 160x120
SENSOR_FAR = 8.0
SCAN_EVERY = 3 # depth scan every N frames; result cached for policy (~17 Hz)
MC_FRAMES = 90 # trigger SLAM rebuild every N rendered frames
GRASS_BLADES = 12000 # merged GrassMesh blade count (GPU-wind on Vulkan); tune for FPS
GRASS_RADIUS = 42.0 # grass disk radius around spawn (fog hides >25 m anyway)
HDR_URL = "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/noon_grass_2k.hdr"
# ── film (--film OUT.mp4) ─────────────────────────────────────────────────────
# One continuous take, one frame per 0.02 s control tick (real time at 50 fps). The script is in ticks; the second trip
# and the end are scheduled from the recovery hand-backs, so the take is as long as the recoveries make it.
FILM_W, FILM_H, FILM_FPS = 1920, 1080, 50
FILM_VX = 1.0 # m/s walking command
FILM_STAND0 = 100 # ticks standing before the walk
FILM_SHOVE = 300 # tick of the F shove
FILM_TRIP1 = 500 # tick of the first G trip
FILM_HB_STAND = 25 # ticks standing after a hand-back before walking on
FILM_TRIP2 = 225 # ticks after the first hand-back: the second trip, from the other side
FILM_END = 325 # ticks after the second hand-back: the end of the take
FILM_TIMEOUT = 500 # ticks after a trip without a hand-back: give up on it
FILM_FOV = 40.0
FILM_AZ = (125.0, 235.0) # camera azimuth from the course heading, deg: left-rear quarter, then right-rear
FILM_DIST = (3.6, 4.8) # m to the target: walking, around a fall (6 m left the fallen robot too small)
FILM_ELEV = (15.0, 20.0) # deg above the target: walking, around a fall
FILM_EYE_CLEAR = 1.0 # m above the terrain under the eye, at least
# ── bench (--bench N) ─────────────────────────────────────────────────────────
BENCH_WARMUP = 120 # frames dropped from the statistics (pipeline/TAA/allocator warmup)
BENCH_BUDGET = 16.7 # ms — a frame above this missed 60 Hz
BENCH_PHASES = ("policy", "physics", "slam", "camera", "scan", "render", "ui")
# ── terrain ────────────────────────────────────────────────────────────────────
def build_terrain_zup(field, world_size, amplitude):
"""Vectorised Z-up triangle soup (XY ground plane, Z = height) with UVs."""
dim = field.shape[0]
half = world_size / 2.0
lin = np.linspace(-half, half, dim, dtype=np.float32)
X, Y = np.meshgrid(lin, lin, indexing='ij')
# field is stored field_[iz][ix] (Z outer, X inner) — transpose so first index → world X
Z = (field.T * amplitude).astype(np.float32)
U, V = np.meshgrid(np.linspace(0, 1, dim, dtype=np.float32),
np.linspace(0, 1, dim, dtype=np.float32), indexing='ij')
ii, jj = np.meshgrid(np.arange(dim - 1), np.arange(dim - 1), indexing='ij')
ii, jj = ii.ravel(), jj.ravel()
def corner(a, b):
return np.stack([X[a, b], Y[a, b], Z[a, b]], 1)
def uvcorner(a, b):
return np.stack([U[a, b], V[a, b]], 1)
v00, v10, v11, v01 = corner(ii, jj), corner(ii+1, jj), corner(ii+1, jj+1), corner(ii, jj+1)
u00, u10, u11, u01 = uvcorner(ii, jj), uvcorner(ii+1, jj), uvcorner(ii+1, jj+1), uvcorner(ii, jj+1)
verts = np.vstack([np.stack([v00, v10, v11], 1).reshape(-1, 3),
np.stack([v00, v11, v01], 1).reshape(-1, 3)])
uvs = np.vstack([np.stack([u00, u10, u11], 1).reshape(-1, 2),
np.stack([u00, u11, u01], 1).reshape(-1, 2)])
g = tp.BufferGeometry()
g.set_attribute("position", np.ascontiguousarray(verts))
g.set_attribute("uv", np.ascontiguousarray(uvs))
g.compute_vertex_normals()
return g
# ── scatter helpers ──────────────────────────────────────────────────────────────
def _in_pond(px, py, hz, pond):
"""True if (px,py) at terrain height hz is at/below the pond waterline and near
its centre — used to keep vegetation out of open water. pond = (cx,cy,wl)|None."""
if pond is None:
return False
cx, cy, wl = pond
return math.hypot(px - cx, py - cy) < 12.0 and hz < wl + 0.2
# ── trees ──────────────────────────────────────────────────────────────────────
def build_tree_variants(seed):
"""Pre-build a small pool of fully-realized tree prototypes (a few seeds per
species from TREE_SPECIES, like forest_demo/vulkan_fjord's variant pooling):
geometry + bark/leaf textures are shared across every placement of a given
prototype — only position/scale/yaw vary per instance. Bounds the number of
unique meshes/textures while still giving each species its own silhouette,
bark, needle/leaf cutout and foliage translucency."""
rng = np.random.default_rng(seed + 9001)
tgen = tp.TreeGenerator(0) # reseeded per variant
variants = []
for preset, name, weight, needs_frond, translucency, t_color in TREE_SPECIES:
for _ in range(VARIANTS_PER_SPECIES):
vseed = int(rng.integers(0, 1_000_000))
tpar = tp.TreeParams()
tpar.seed = vseed
tp.apply_tree_preset(preset, tpar)
if preset != 1: # Colonise species (whorl conifer is O(whorls), not iterations)
tpar.max_iterations = 140
tgen.reseed(vseed)
tgen.build_skeleton(tpar)
trunk_geo = tgen.make_trunk_geometry(tpar)
leaf_geo = tgen.make_leaf_geometry(tpar)
bark_alb, bark_nrm = tp.make_bark_textures(192, vseed, tpar.bark_color)
trunk_mat = tp.MeshStandardMaterial()
trunk_mat.map = bark_alb
trunk_mat.normal_map = bark_nrm
trunk_mat.normal_scale = tp.Vector2(0.7, 0.7)
trunk_mat.roughness = 0.88
# Conifer fronds want the elongated needle cutout; broadleaf species the
# round leaf-cluster atlas (mirrors vulkan_fjord.cpp's makeLeafTex).
leaf_tex = (tp.make_needle_frond_texture(224, vseed, tpar.leaf_color) if needs_frond
else tp.make_leaf_texture(224, vseed, tpar.leaf_color))
leaf_mat = tp.MeshStandardMaterial()
leaf_mat.map = leaf_tex
leaf_mat.alpha_test = 0.5 # discard the transparent gaps → leafy silhouette
leaf_mat.roughness = 0.88
leaf_mat.side = tp.Side.Double
leaf_mat.vertex_colors = True # per-leaf tint variation baked into the geometry
# Foliage translucency: backlit canopy glow (Vulkan deferred only; no-op on GL).
leaf_mat.translucency = translucency
leaf_mat.translucency_color = tp.Color(*t_color)
variants.append(dict(name=name, weight=weight, is_willow=(name == "willow"),
trunk_geo=trunk_geo, leaf_geo=leaf_geo,
trunk_mat=trunk_mat, leaf_mat=leaf_mat,
trunk_radius=tpar.trunk_radius,
# rough crown extent, for the film camera's sightline test only
crown_r=max(float(tpar.crown_radius_x), float(tpar.crown_radius_z), 1.0),
trunk_h=float(tpar.trunk_height),
height=float(tpar.trunk_height) + float(tpar.crown_height)))
print(f"[trees] built {len(variants)} variants ({len(TREE_SPECIES)} species x {VARIANTS_PER_SPECIES})")
return variants
def scatter_trees(scene, gen, params, variants, n=TREE_COUNT, seed=0, world=None, pond=None, placements=None):
"""Scatter trees drawn from a pre-built variant pool (build_tree_variants).
When `world` is given, add a static box collider per trunk (a "tree stub")
so Spot bumps into the trunks; the leafy canopy stays non-colliding.
`placements`, if a list, gets (x, y, ground z, crown radius, trunk height, height) per tree.
Returns the list of collider proxy meshes (keep them alive)."""
rng = np.random.default_rng(seed)
half = params.world_size / 2.0 - 4.0
proxies = []
base_w = np.array([v["weight"] for v in variants], np.float64)
willow_mask = np.array([v["is_willow"] for v in variants])
pond_cx, pond_cy = (pond[0], pond[1]) if pond is not None else (None, None)
placed = 0
attempts = 0
while placed < n and attempts < n * 15:
attempts += 1
px, py = float(rng.uniform(-half, half)), float(rng.uniform(-half, half))
if math.hypot(px, py) < CLEAR_R:
continue
hz = float(gen.height_at(px, py, params))
if _in_pond(px, py, hz, pond): # don't plant trees in open water
continue
w = base_w
if pond_cx is not None and math.hypot(px - pond_cx, py - pond_cy) < WATER_BIAS_R:
w = np.where(willow_mask, base_w * 6.0, base_w) # willows favour the water's edge
v = variants[int(rng.choice(len(variants), p=w / w.sum()))]
scale = float(rng.uniform(0.75, 1.3))
yaw = float(rng.uniform(0, 2 * math.pi))
for geo, mat in ((v["trunk_geo"], v["trunk_mat"]), (v["leaf_geo"], v["leaf_mat"])):
m = tp.Mesh(geo, mat)
m.rotation.y = yaw # spin around the (still Y-up) trunk axis first...
m.rotation.x = math.pi / 2 # ...then stand it up: Y-up tree → Z-up world
m.position.set(px, py, hz)
m.scale.set(scale, scale, scale)
m.cast_shadow = True
scene.add(m)
if world is not None:
# Tall thin box collider on the lower trunk (covers Spot's height);
# the canopy above it is leaves → no collision. Width tracks the
# species' actual trunk radius so spruces/oaks don't share one size.
tw = 0.35 * scale * max(v["trunk_radius"] / 0.15, 0.6)
stub = tp.Mesh(tp.BoxGeometry(tw, tw, 2.5), v["trunk_mat"])
stub.position.set(px, py, hz + 1.25)
world.add_static(stub)
proxies.append(stub)
if placements is not None:
placements.append((px, py, hz, v["crown_r"] * scale, v["trunk_h"] * scale, v["height"] * scale))
placed += 1
print(f"[trees] placed {placed}/{n}")
return proxies
# ── stones ───────────────────────────────────────────────────────────────────────
def make_rock_geometry(seed):
"""Low-poly faceted boulder: a 5x7 sphere displaced by a few smooth lumps
(port of forest_demo's makeRock). Non-indexed soup; pair with flat_shading."""
rng = np.random.default_rng(seed)
lat_segs, lon_segs = 5, 7
p1, p2, p3 = rng.uniform(-math.pi, math.pi, 3)
theta = np.linspace(0, math.pi, lat_segs + 1, dtype=np.float32)
phi = np.linspace(0, 2 * math.pi, lon_segs + 1, dtype=np.float32)
T, P = np.meshgrid(theta, phi, indexing='ij') # (6,8)
sinT, cosT = np.sin(T), np.cos(T)
nx, ny, nz = sinT * np.cos(P), cosT, sinT * np.sin(P)
# Every phi-dependent term is gated by sinT so it vanishes at the poles
# (theta = 0, pi). Ungated cos(3phi)/cos(5phi) give each pole vertex a
# DIFFERENT radius, smearing the single pole point into a fan of thin sliver
# triangles whose sub-pixel coverage toggles with the TAA jitter and flickers
# uniformly every frame. Gated, the pole vertices coincide -> zero-area
# triangles that rasterize to nothing, leaving a clean pole fan.
disp = (1.0 + sinT * (0.30 * np.sin(2 * P + p1) + 0.24 * np.cos(3 * P + p2)
+ 0.14 * np.cos(5 * P + 4 * T + p1))
+ 0.22 * np.sin(3 * T + p3))
disp = np.clip(disp, 0.6, 1.5)
pos = np.stack([nx * disp, ny * disp, nz * disp], -1).reshape(-1, 3)
nrm = np.stack([nx, ny, nz], -1).reshape(-1, 3)
rowV = lon_segs + 1
idx = []
for la in range(lat_segs):
for lo in range(lon_segs):
a = la * rowV + lo; b = a + rowV
idx += [a, a + 1, b, a + 1, b + 1, b] # CCW from outside
idx = np.array(idx, np.int64)
g = tp.BufferGeometry()
g.set_attribute("position", np.ascontiguousarray(pos[idx], np.float32))
g.set_attribute("normal", np.ascontiguousarray(nrm[idx], np.float32))
return g
def scatter_stones(scene, gen, params, n=35, seed=0, world=None, pond=None, placements=None):
"""Scatter boulders. When `world` is given, add a static sphere collider per
stone so Spot bumps into them. `placements`, if a list, gets (x, y, ground z, radius).
Returns the collider proxy meshes."""
rng = np.random.default_rng(seed + 7)
half = params.world_size / 2.0 - 4.0
proxies = []
rgeos = [make_rock_geometry(s) for s in (1, 2, 3)]
mat = tp.MeshStandardMaterial()
mat.color = 0x4e4a44
mat.roughness = 1.0
mat.flat_shading = True # crisp facets on the low-poly boulders
placed = attempts = 0
while placed < n and attempts < n * 15:
attempts += 1
px, py = float(rng.uniform(-half, half)), float(rng.uniform(-half, half))
if math.hypot(px, py) < 2.5:
continue
hz = float(gen.height_at(px, py, params))
if _in_pond(px, py, hz, pond):
continue
s = 0.30 + float(rng.uniform()) * 0.55
m = tp.Mesh(rgeos[int(rng.integers(0, len(rgeos)))], mat)
m.position.set(px, py, hz + s * 0.30) # mostly proud of the ground, partly embedded
m.scale.set(s, s, s * 0.8) # slightly squashed boulders
m.rotation.x = float(rng.uniform(0, 2 * math.pi))
m.rotation.y = float(rng.uniform(0, 2 * math.pi))
m.rotation.z = float(rng.uniform(0, 2 * math.pi))
m.cast_shadow = True
m.receive_shadow = True
scene.add(m)
if world is not None:
sph = tp.Mesh(tp.SphereGeometry(s * 0.8, 8, 6), mat)
sph.position.set(px, py, hz + s * 0.30)
world.add_static(sph)
proxies.append(sph)
if placements is not None:
placements.append((px, py, hz, s * 0.8))
placed += 1
print(f"[stones] placed {placed}/{n}")
return proxies
# ── bushes (shrub-variant trees, like forest_demo) ────────────────────────────────
def scatter_bushes(scene, gen, params, n=50, seed=0, pond=None, placements=None):
"""Short trunk + wide low crown shrubs. Each of a handful of prototypes gets
its own seed, crown shape/size, leaf colour and bark texture (mirrors the
tree variant pool) so the understory doesn't read as one shrub copy-pasted.
`placements`, if a list, gets (x, y, ground z, crown radius, height) per bush."""
rng = np.random.default_rng(seed + 3)
half = params.world_size / 2.0 - 4.0
N_VARIANTS = 6
tgen = tp.TreeGenerator(0)
variants = []
for _ in range(N_VARIANTS):
s = int(rng.integers(0, 1_000_000))
tpar = tp.TreeParams()
tpar.seed = s
tpar.trunk_height = 0.35 + float(rng.uniform(0, 0.30))
tpar.trunk_radius = 0.05 + float(rng.uniform(0, 0.03))
tpar.crown_shape = tp.CrownShape.Hemisphere if rng.uniform() < 0.6 else tp.CrownShape.Sphere
r = 0.85 + float(rng.uniform(0, 0.65))
tpar.crown_radius_x = r; tpar.crown_radius_z = r
tpar.crown_height = r * (1.05 if tpar.crown_shape == tp.CrownShape.Hemisphere else 1.35)
tpar.attractor_count = 220 + int(rng.integers(0, 100))
tpar.influence_distance = 2.0; tpar.kill_distance = 0.45
tpar.segment_length = 0.22; tpar.max_iterations = 130; tpar.randomness = 0.12
tpar.radial_segments = 5
tpar.leaf_style = tp.LeafStyle.CrossQuad
tpar.leaf_size = 0.32 + float(rng.uniform(0, 0.22))
tpar.leaf_density = 0.82 + float(rng.uniform(0, 0.16))
tpar.leaves_per_cluster = int(rng.integers(4, 7))
tpar.leaf_spread = 0.28 + float(rng.uniform(0, 0.22))
hue = float(rng.uniform(-0.03, 0.06)) # per-bush leaf hue jitter (olive → fresh green)
tpar.leaf_color = [0.13 + hue * 0.6, 0.33 + hue, 0.11 + hue * 0.3]
tpar.bark_color = [0.30, 0.22, 0.12]
tgen.reseed(s); tgen.build_skeleton(tpar)
trunk_geo = tgen.make_trunk_geometry(tpar)
leaf_geo = tgen.make_leaf_geometry(tpar)
bark_alb, _ = tp.make_bark_textures(64, s, tpar.bark_color)
trunk_mat = tp.MeshStandardMaterial(); trunk_mat.map = bark_alb; trunk_mat.roughness = 0.9
leaf_mat = tp.MeshStandardMaterial()
leaf_mat.map = tp.make_leaf_texture(224, s, tpar.leaf_color)
leaf_mat.alpha_test = 0.5
leaf_mat.roughness = 0.9
leaf_mat.side = tp.Side.Double
leaf_mat.vertex_colors = True
leaf_mat.translucency = 0.35 # backlit canopy glow (Vulkan deferred; no-op on GL)
leaf_mat.translucency_color = tp.Color(0.55, 0.85, 0.30)
variants.append((trunk_geo, leaf_geo, trunk_mat, leaf_mat,
r, float(tpar.trunk_height) + float(tpar.crown_height)))
placed = attempts = 0
while placed < n and attempts < n * 15:
attempts += 1
px, py = float(rng.uniform(-half, half)), float(rng.uniform(-half, half))
if math.hypot(px, py) < 3.0:
continue
hz = float(gen.height_at(px, py, params))
if _in_pond(px, py, hz, pond): # keep bushes out of open water
continue
trunk_geo, leaf_geo, trunk_mat, leaf_mat, crown_r, top = variants[int(rng.integers(0, len(variants)))]
s = 0.7 + float(rng.uniform()) * 0.7
if placements is not None:
placements.append((px, py, hz, crown_r * s, top * s))
yaw = float(rng.uniform(0, 2 * math.pi))
for geo, mat in ((trunk_geo, trunk_mat), (leaf_geo, leaf_mat)):
m = tp.Mesh(geo, mat)
m.rotation.y = yaw
m.rotation.x = math.pi / 2 # Y-up → Z-up (like the trees)
m.position.set(px, py, hz)
m.scale.set(s, s, s)
m.cast_shadow = True
scene.add(m)
placed += 1
print(f"[bushes] placed {placed}/{n} ({len(variants)} variants)")
# ── grass ────────────────────────────────────────────────────────────────────────
def build_grass_field(gen, params, n_blades, radius, seed, clear_r=0.0, pond=None):
"""One merged GrassMesh geometry (non-indexed triangle soup) for the Vulkan
GPU-wind path. Blades are Y-up in local space (the wind compute bends local
X/Z, keeping Y), and the field is baked so rotation.x=pi/2 on the mesh stands
them up at terrain height in the Z-up world — same convention as the trees.
Returns (geometry, blade_count). Needs a per-vertex 'heightFrac' attribute
(0 at base, 1 at tip) which drives the sway weighting."""
rng = np.random.default_rng(seed + 4242)
seg, wbase = 4, 0.05
rows = seg + 1
t = np.linspace(0.0, 1.0, rows, dtype=np.float32) # height fraction per row
w = wbase * (1.0 - t) # taper to a point at the tip
# 10-vertex blade template (Y-up): two verts (L/R) per row
tpl_pos = np.empty((rows, 2, 3), np.float32)
tpl_pos[:, 0] = np.stack([-w, t, np.zeros_like(t)], 1)
tpl_pos[:, 1] = np.stack([ w, t, np.zeros_like(t)], 1)
tpl_pos = tpl_pos.reshape(-1, 3) # (10,3)
tpl_hf = np.repeat(t, 2) # heightFrac = blade-local y
tpl_uv = np.stack([np.tile([0.0, 1.0], rows), tpl_hf], 1).astype(np.float32)
c_bot = np.array([0.06, 0.13, 0.04], np.float32)
c_top = np.array([0.22, 0.40, 0.13], np.float32)
tpl_col = (c_bot + (c_top - c_bot) * tpl_hf[:, None]).astype(np.float32)
n0 = np.array([0.0, 0.85, 0.53], np.float32); n0 /= np.linalg.norm(n0)
tpl_nrm = np.tile(n0, (rows * 2, 1))
# expand 10 indexed verts → 24-vertex non-indexed soup (set_index isn't bound):
# two triangles per segment, winding matching makeGrassField()
soup = []
for s in range(seg):
a = s * 2
soup += [a, a + 1, a + 2, a + 1, a + 3, a + 2]
soup = np.array(soup, np.int64) # (24,)
sp, sh, su, sc, sn = (tpl_pos[soup], tpl_hf[soup], tpl_uv[soup],
tpl_col[soup], tpl_nrm[soup]) # (24, …)
V = soup.shape[0]
# scatter blades in TUFTS (grass grows in clumps) over a disk, skipping a
# clearing around spawn. One terrain-height query per tuft (blades in a tuft
# are within a few cm, terrain is locally flat) keeps the build cheap.
TUFT, SPREAD = 6, 0.12 # blades per tuft, max in-tuft offset (m)
xs, ys, hs, yaws, sxs, hgts, tries = [], [], [], [], [], [], 0
while len(xs) < n_blades and tries < n_blades * 4:
tries += 1
ang = rng.uniform(0, 2 * math.pi); rr = math.sqrt(rng.uniform(0, 1)) * radius
cx, cy0 = math.cos(ang) * rr, math.sin(ang) * rr
if math.hypot(cx, cy0) < clear_r:
continue
hz = float(gen.height_at(cx, cy0, params))
if _in_pond(cx, cy0, hz, pond): # no grass tufts in open water
continue
for _ in range(TUFT):
xs.append(cx + rng.uniform(-SPREAD, SPREAD))
ys.append(cy0 + rng.uniform(-SPREAD, SPREAD))
hs.append(hz)
yaws.append(rng.uniform(0, 2 * math.pi))
sxs.append(0.7 + rng.uniform(0, 1) * 0.6)
hgts.append(0.10 + rng.uniform(0, 1) * 0.14) # short meadow grass
n = len(xs)
wx = np.array(xs, np.float32); wy = np.array(ys, np.float32); h = np.array(hs, np.float32)
yaw = np.array(yaws, np.float32); sx = np.array(sxs, np.float32); hg = np.array(hgts, np.float32)
cy, sy = np.cos(yaw)[:, None], np.sin(yaw)[:, None]
# per-blade: scale (sx,hg,sx) → R_y(yaw) → translate to merged-local (wx, h, -wy)
P = sp[None] * np.stack([sx, hg, sx], 1)[:, None, :] # (n,V,3)
px = P[:, :, 0] * cy + P[:, :, 2] * sy + wx[:, None]
py = P[:, :, 1] + h[:, None]
pz = -P[:, :, 0] * sy + P[:, :, 2] * cy + (-wy)[:, None]
pos = np.stack([px, py, pz], -1).reshape(-1, 3).astype(np.float32)
nx = sn[None, :, 0] * cy + sn[None, :, 2] * sy
ny = np.tile(sn[:, 1][None], (n, 1))
nz = -sn[None, :, 0] * sy + sn[None, :, 2] * cy
nrm = np.stack([nx, ny, nz], -1).reshape(-1, 3).astype(np.float32)
nrm /= np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-6)
g = tp.BufferGeometry()
g.set_attribute("position", np.ascontiguousarray(pos))
g.set_attribute("normal", np.ascontiguousarray(nrm))
g.set_attribute("uv", np.ascontiguousarray(np.tile(su, (n, 1))))
g.set_attribute("color", np.ascontiguousarray(np.tile(sc, (n, 1))))
g.set_attribute("heightFrac", np.ascontiguousarray(np.tile(sh, n)[:, None]))
return g, n
# ── pond (Vulkan FFT ocean) ───────────────────────────────────────────────────────
def add_pond(scene, gen, params, max_r=20.0, exclude_r=7.0, depth=1.4):
"""Drop an FFT water surface into the deepest terrain basin within an annulus
[exclude_r, max_r] around spawn (Vulkan only — tp.Ocean is absent on GL
builds). Searching a disk (vs a forward box) lands in a real bowl with terrain
rising on all sides, so the flat water plane reads as a contained pond rather
than a hillside flood. Returns (pond, water_level) or (None, None). The
renderer auto-animates the waves each frame."""
if not hasattr(tp, "Ocean"):
print("[pond] skipped (needs the Vulkan build)")
return None, None
best = None
for x in np.linspace(-max_r, max_r, 49):
for y in np.linspace(-max_r, max_r, 49):
r = math.hypot(float(x), float(y))
if not (exclude_r <= r <= max_r):
continue
h = float(gen.height_at(float(x), float(y), params))
if best is None or h < best[2]:
best = (float(x), float(y), h)
lx, ly, lh = best
wl = lh + depth
# Ocean is scale-aware now: size=32 alone derives pond-scale cascade tiles,
# clear-ish freshwater absorption (the bottom is genuinely visible through
# the surface via the shallow-water shading), and near-zero natural
# whitecap foam — the manual tile_size_0/1 overrides that used to fake
# this are gone. tile_size_2=0 stays: the deferred fine-chop normal is
# sampled in world XZ (Y-up assumption), so on this ROTATED (Z-up) ocean
# the finest cascade would render as 1-D stripes.
pond = tp.Ocean(size=32.0, resolution=128, wind_speed=3.0, wind_theta=0.5,
choppiness=0.4, wave_scale=0.4, tile_size_2=0.0, fft_size=256)
# Murkier than the freshwater default: this pond is only ~0.4 m deep, and
# at that depth clear water reads as wet sand. A short attenuation distance
# veils the bottom into a proper green-brown; thickness scales the veil's
# own brightness (see MeshPhysicalMaterial.thickness).
mat = pond.material
mat.attenuation_color = 0x245238 # green-brown murk
mat.attenuation_distance = 1.2 # ~a metre of visibility
mat.thickness = 0.6
pond.rotation.x = math.pi / 2 # Y-up ocean → Z-up world (waves rise in +Z)
pond.position.set(lx, ly, wl-1)
scene.add(pond)
print(f"[pond] basin ({lx:.1f},{ly:.1f}) water_level={wl:.2f} m")
return pond, wl
# ── SLAM mapper ────────────────────────────────────────────────────────────────
class SlamMapper:
"""Accumulates scan hits → VoxelGrid → marching-cubes surface (background thread)."""
VOXEL = 0.12
CELL = 0.14
RAD = 0.22 # tight: surface hugs scan hits rather than ballooning above them
ISO = 0.55
MAX_VERTS = 600_000 # preallocated surface capacity (200k triangles)
def __init__(self, scene):
self.scene = scene
self.grid = tp.VoxelGrid(self.VOXEL, max_points_per_voxel=3, min_spacing=0.12)
self._pending= [None]
self._busy = [False]
self._visible= [True]
self._sent = None # scanner cells already pushed into the grid
self._lock = threading.Lock()
# Bumped by clear(). A rebuild carries the generation it started in and
# refuses to publish into a newer one — otherwise a worker that was
# already marching cubes when the user hit R finishes a second later and
# hands apply_pending() the PRE-reset surface, which then reappears on
# screen over the freshly cleared map. Blanking the draw range in
# clear() cannot prevent that: the stale result arrives afterwards.
self._gen = 0
# ONE mesh + geometry for the whole session, preallocated at capacity:
# a rebuild overwrites the leading rows and moves the draw range.
# Building a fresh Mesh per rebuild instead cost ~11 ms inside the NEXT
# render() — a new multi-MB vertex buffer plus the scene add/remove
# forcing a full entry re-expansion — i.e. a visible hitch every
# MC_FRAMES. In-place updates keep the entry (and its TAA history) put.
g = tp.BufferGeometry()
g.set_attribute("position", np.zeros((self.MAX_VERTS, 3), np.float32))
g.set_attribute("normal", np.zeros((self.MAX_VERTS, 3), np.float32))
g.set_draw_range(0, 0)
self._draw_n = 0 # mirrors the geometry's draw count (not readable back)
mat = tp.MeshStandardMaterial()
mat.color = 0x55bbff
mat.side = tp.Side.Double
# Wireframe routes the mesh through the renderer's raster overlay path: on Vulkan it
# gets the kSnapWire flag → EXCLUDED from the path-tracer TLAS (so the depth sensor
# never hits its own reconstruction → no self-contamination), and is drawn depth-tested
# over the PT frame. On GL it simply renders as wireframe (still hidden during scans).
mat.wireframe = True
self._geo = g
self._surf = tp.Mesh(g, mat)
self._surf.frustum_culled = False
self._apply_visibility() # nothing built yet -> hidden
# Added to the scene on the FIRST surface, not here: a 600k-vertex
# all-zero geometry parked at the origin is still a scene entry, and the
# depth sensor traces the scene rather than the draw range.
self._added = False
def insert_scanner(self, scanner):
"""Push the cells the scanner has newly filled in since the last call.
The scanner's H is the *accumulated* elevation map — 535x535 cells over the
80 m world here — so converting all of it every scan means walking a third
of a million cells and re-offering tens of thousands of points the voxel
grid then rejects. Only a first-time cell can add a voxel: later EMA nudges
move a cell by centimetres, far inside VOXEL / min_spacing. So track which
cells have been sent and hand over just the delta, which is bounded by the
depth camera's footprint instead of growing with everything explored.
"""
valid = ~np.isnan(scanner.H)
if self._sent is None:
self._sent = np.zeros_like(valid)
new = valid & ~self._sent
if not new.any():
return
self._sent |= new
ix, iy = np.nonzero(new)
self.grid.insert_array(np.stack([ix * scanner.cell + scanner.x0,
iy * scanner.cell + scanner.y0,
scanner.H[new]], axis=1).astype(np.float32))
def trigger_rebuild(self):
with self._lock:
if self._busy[0] or self.grid.voxel_count < 30:
return
pts = self.grid.collect()
self._busy[0] = True
gen = self._gen
threading.Thread(target=self._worker, args=(pts, gen), daemon=True).start()
def _worker(self, pts, gen):
try:
field = tp.splat_points_to_field(pts, self.CELL, self.RAD, max_nodes=6_000_000)
iso = tp.marching_cubes(field, self.ISO)
with self._lock:
if gen != self._gen:
return # cleared while we were building — drop it
self._pending[0] = iso if not iso.empty else None
finally:
with self._lock:
self._busy[0] = False
def apply_pending(self):
with self._lock:
iso = self._pending[0]; self._pending[0] = None
if iso is None:
return
pos, nrm = iso.positions, iso.normals
n = min(pos.shape[0], self.MAX_VERTS)
n -= n % 3 # whole triangles only
if n <= 0:
self._geo.set_draw_range(0, 0)
self._draw_n = 0
self._apply_visibility()
return
if pos.shape[0] > self.MAX_VERTS:
print(f"[slam] surface clipped to {self.MAX_VERTS} of {pos.shape[0]} verts")
self._geo.update_attribute("position", pos[:n])
self._geo.update_attribute("normal", nrm[:n])
self._geo.set_draw_range(0, n)
self._draw_n = n
self._apply_visibility()
if not self._added:
self.scene.add(self._surf) # once per session, not per rebuild
self._added = True
def clear(self):
"""Hide the displayed surface and discard all accumulated data.
Also invalidates any rebuild currently running, so its result is dropped
rather than restoring the pre-clear surface a moment later.
"""
with self._lock:
self._gen += 1
self._pending[0] = None
self.grid.clear()
self._sent = None
self._geo.set_draw_range(0, 0)
self._draw_n = 0
self._apply_visibility()
@property
def busy(self): return self._busy[0]
@property
def voxels(self): return self.grid.voxel_count
def _apply_visibility(self):
"""Show the surface only when it actually has triangles.
Dropping the draw range to 0 is NOT enough to blank it. The wireframe material routes
this mesh through the renderer's overlay line path, which caches the line list it built
from the geometry and refreshes that cache on the geometry's VERSION. update_attribute
bumps the version (so a rebuild shows up), but set_draw_range alone does not — so after
clear() the renderer keeps drawing the last cached surface until the next rebuild
replaces it. That is what made the SLAM surface survive a reset. Visibility is evaluated
per frame during traversal, so gating on it blanks the mesh immediately.
"""
self._surf.visible = bool(self._visible[0] and self._draw_n > 0)
@property
def visible(self): return self._visible[0]
@visible.setter
def visible(self, v):
self._visible[0] = v
self._apply_visibility()
# ── path trail ────────────────────────────────────────────────────────────────
class PathTrail:
MAX = 3000
def __init__(self, scene):
g = tp.BufferGeometry()
g.set_attribute("position", np.zeros((self.MAX, 3), np.float32))
g.set_draw_range(0, 0)
mat = tp.LineBasicMaterial(); mat.color = 0xffffff
self.line = tp.Line(g, mat); self.line.frustum_culled = False
self.line.visible = False
self._g = g; self._pts = []; self._prev = None
scene.add(self.line)
@property
def visible(self): return self.line.visible
@visible.setter
def visible(self, v): self.line.visible = v
def update(self, rs):
p = (float(rs[0]), float(rs[1]), float(rs[2]) + 0.06)
if self._prev and math.dist(p, self._prev) < 0.15:
return
self._pts.append(p)
if len(self._pts) > self.MAX:
self._pts.pop(0)
arr = np.array(self._pts, np.float32)
buf = np.zeros((self.MAX, 3), np.float32)
buf[:len(arr)] = arr
self._g.update_attribute("position", buf)
self._g.set_draw_range(0, len(self._pts))
self._prev = p
def clear(self):
self._pts.clear(); self._prev = None
self._g.update_attribute("position", np.zeros((self.MAX, 3), np.float32))
self._g.set_draw_range(0, 0)
# ── bench report ──────────────────────────────────────────────────────────────
def _bench_report(bt, total, period, scan_only, gpu):
"""Print the --bench summary. `bt`/`total`/`period`/`gpu` are per-frame and
index-aligned with the warmup already trimmed; `scan_only` holds the scan
phase of scan frames only. Seconds everywhere except `gpu`, which is the
renderer's frame_timings dict and already milliseconds."""
n = len(total)
if n == 0:
print(f"[bench] nothing measured — N must exceed the {BENCH_WARMUP}-frame warmup")
return
def ms(a):
return np.asarray(a, np.float64) * 1000.0
per, tot = ms(period), ms(total)
over = int((per > BENCH_BUDGET).sum())
print()
print(f"== bench: {n} frames measured ({BENCH_WARMUP} warmup frames discarded) ==")
print(f"overall {1000.0 / max(float(per.mean()), 1e-9):.2f} fps")
print(f" period ms mean {per.mean():8.3f} p50 {np.percentile(per, 50):8.3f}"
f" p95 {np.percentile(per, 95):8.3f} p99 {np.percentile(per, 99):8.3f}"
f" max {per.max():8.3f}")
print(f" over {BENCH_BUDGET} ms: {over}/{n} frames ({100.0 * over / n:.1f}%)")
P = {k: ms(bt[k]) for k in BENCH_PHASES}
print()
print("cpu phases (ms)")
print(f" {'phase':<10}{'mean':>10}{'p50':>10}{'p95':>10}{'max':>10}")
for k in BENCH_PHASES + ("total",):
a = tot if k == "total" else P[k]
print(f" {k:<10}{a.mean():10.3f}{np.percentile(a, 50):10.3f}"
f"{np.percentile(a, 95):10.3f}{a.max():10.3f}")
smask = P["scan"] > 0.0
print()
print("scan vs non-scan frames (frame total, ms)")
for label, sel in (("scan", smask), ("non-scan", ~smask)):
if int(sel.sum()):
a = tot[sel]
print(f" {label:<10}n={int(sel.sum()):<7}mean {a.mean():8.3f} p95 {np.percentile(a, 95):8.3f}")
if len(scan_only):
a = ms(scan_only)
print(f" scan phase itself: mean {a.mean():8.3f} p95 {np.percentile(a, 95):8.3f}")
keys = sorted(gpu[0].keys()) if gpu and gpu[0] else []
if keys:
print()
print("renderer frame_timings (ms)")
print(f" {'key':<22}{'mean':>10}{'p95':>10}")
for k in keys:
a = np.asarray([float(g.get(k, 0.0)) for g in gpu], np.float64)
print(f" {k:<22}{a.mean():10.3f}{np.percentile(a, 95):10.3f}")
# hitch attribution — which phase dominated each over-budget frame
stack = np.stack([P[k] for k in BENCH_PHASES]) # (phase, frame)
hits = np.nonzero(per > BENCH_BUDGET)[0]
print()
print(f"hitch blame ({len(hits)} frames over {BENCH_BUDGET} ms)")
if len(hits):
top = stack[:, hits].argmax(axis=0)
counts = sorted(((int((top == j).sum()), k) for j, k in enumerate(BENCH_PHASES)),
reverse=True)
for c, k in counts:
if c:
print(f" {k:<10}{c:7d} ({100.0 * c / len(hits):.1f}%)")
print("worst 5 frames")
for i in hits[np.argsort(per[hits])[::-1][:5]]:
order = np.argsort(stack[:, i])[::-1][:3]
phases = " ".join(f"{BENCH_PHASES[int(j)]} {stack[int(j), i]:.2f}" for j in order)
gt = float(gpu[int(i)].get("gpu_total_ms", 0.0)) if int(i) < len(gpu) else 0.0
print(f" frame {BENCH_WARMUP + int(i) + 1:<7} period {per[i]:8.3f} "
f"{phases} gpu_total {gt:.2f}")
# ── main ──────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--amplitude", type=float, default=AMPLITUDE)
ap.add_argument("--model", default=os.path.join(_HERE, "spot_steps.pt"),
help="policy checkpoint (falls back to <model>_latest.pt)")
ap.add_argument("--shot", metavar="PNG")
ap.add_argument("--windowed", action="store_true",
help="1200x720 window instead of fullscreen")
ap.add_argument("--bench", type=int, default=0, metavar="N",
help="profile N frames: vsync off, auto-walk forward, print timing summary, exit")
ap.add_argument("--recovery-model", dest="recovery_model", default=os.path.join(_HERE, "spot_recovery.pt"),
help="fall-recovery policy (train_spot_recovery.py): takes over when Spot tips past up_z "
f"{RECOVER_UP}, hands back once it has stood still for {RECOVER_SETTLE} ticks. F shoves, G trips. "
"Default: the shipped spot_recovery.pt beside this file (skipped if missing)")
ap.add_argument("--no-recovery", dest="no_recovery", action="store_true",
help="walking policy only: no recovery hand-over, and the legacy viewer plant unless the walking "
"checkpoint trained with real torque limits")
ap.add_argument("--self-collision", dest="self_collision", choices=("auto", "on", "off"), default="auto",
help="auto: on when a recovery policy is loaded (it trained with it); a walk + jump walker "
"(train_spot_steps.py --jump) trained without it")
ap.add_argument("--headless-vx", dest="headless_vx", type=float, default=1.0,
help="with --shot: the headless walk's forward command (0 = stand; --jump-at then jumps from a stand)")
ap.add_argument("--jump-at", dest="jump_at", type=int, default=0, metavar="TICK",
help="with --shot and a walk + jump walker: press J at this control tick of the headless walk and log "
"the flight, clearance, height, up_z and joint speeds over the jump window")
ap.add_argument("--knock-at", dest="knock_at", type=int, default=0, metavar="TICK",
help="with --shot: trip Spot (G's push) at this control tick of the headless walk (tests the hand-over)")
ap.add_argument("--trip-probe", dest="trip_probe", default="", metavar="OUT.json",
help="headless: 10 trips of the standing walking policy per dv of TRIP_LADDER up to the first that "
"tips it past up_z 0.5 in >= 8 of 10, then 10 F shoves; writes OUT.json and exits")
ap.add_argument("--film", default="", metavar="OUT.mp4",
help=f"headless: render the scripted fall-recovery take (stand, walk, F shove, G trip, recovery, a "
f"trip from the other side, recovery, walk away) at {FILM_W}x{FILM_H} {FILM_FPS} fps to OUT.mp4 "
"(H.264 yuv420p); PNG frames go to OUT_frames/ and are deleted after the encode")
ap.add_argument("--film-keep-frames", dest="film_keep_frames", action="store_true",
help="with --film: keep the PNG frames after the encode")
ap.add_argument("--film-dry", dest="film_dry", action="store_true",
help="with --film: run the take without keeping frames and print the up_z / control-mode timeline "
"and the camera sightline check")
ap.add_argument("--film-stills", dest="film_stills", default="", metavar="T1,T2,...",
help="with --film: keep only the frames at these ticks, as OUT_still_TTTT.png, and no video")
ap.add_argument("--film-frames", dest="film_frames", type=int, default=0, metavar="N",
help="with --film: stop after N frames (a cost measurement)")
ap.add_argument("--film-scale", dest="film_scale", type=float, default=0.75,
help="with --film: renderer render_scale (FSR reconstructs to the film size)")
args = ap.parse_args()
if args.no_recovery or (args.recovery_model and not os.path.exists(args.recovery_model)):
args.recovery_model = ""
assert tp.HAS_PHYSX, "needs a PhysX-enabled threepp build"
headless = bool(args.shot) or bool(args.trip_probe) or bool(args.film)
bench = int(args.bench)
# ── assets ────────────────────────────────────────────────────────────────
assets = fetch_assets()
model_path = args.model
if not os.path.exists(model_path):
_latest = os.path.splitext(model_path)[0] + "_latest.pt"
model_path = _latest if os.path.exists(_latest) else model_path
ac, norm, _meta = load_policy(model_path, device="cpu")
ac.eval()
# A stand_mode checkpoint saw a (0,0) clock with the phase held whenever its command was exactly zero; without
# the same sentinel here it would trot in place off-distribution (play_spot_steps.py does the same).
stand_mode = bool(_meta.get("stand_mode", False))
print(f"[policy] {os.path.basename(model_path)}" + (" (stand mode: no keys = stand still)" if stand_mode else ""))
# A walk + jump checkpoint (train_spot_steps.py --jump) reads 98 inputs: the walking 96 plus the jump flag and phase over
# the jump window that J starts, and it trained with a joint target rate limit (meta qd_max), applied here too.
walk_jump = bool(_meta.get("jump_obs", False))
walk_qd = float(_meta["qd_max"]) if _meta.get("qd_max") else None
walk_win = int(_meta.get("jump_window", 60))
if walk_jump or walk_qd:
print("[policy] " + (f"walk + jump: J jumps ({walk_win}-tick window, standing or walking), " if walk_jump else "")
+ (f"joint targets <= {walk_qd:g} rad/s" if walk_qd else "no joint target limit"))
rec = None
if args.recovery_model:
rec_ac, rec_norm, _rec_meta = load_policy(args.recovery_model, device="cpu")
rec_ac.eval()
# The recovery checkpoint's own action scale and target clip while it drives (wave 2 trains at 0.5 / 1.0,
# clipped to the joint limits; a pilot meta has neither = 0.2 unclipped). The walking policy keeps ACTION_SCALE.
rec_scale = float(_rec_meta.get("action_scale", ACTION_SCALE))
rec_lo = np.array([LIM[j][0] for _ in range(4) for j in ("hx", "hy", "kn")], np.float32) \
if _rec_meta.get("target_clip", False) else None # add order, as set_drive_targets takes them
rec_hi = np.array([LIM[j][1] for _ in range(4) for j in ("hx", "hy", "kn")], np.float32)
# and its target rate limit (wave 3, spot_recovery_env.act), none in a wave 2 or pilot meta
rec_qd = float(_rec_meta["qd_max"]) if _rec_meta.get("qd_max") else None
rec = (rec_ac, rec_norm, rec_scale, rec_lo, rec_hi, rec_qd, float(_rec_meta.get("dt", 0.02)))
print(f"[recovery] {os.path.basename(args.recovery_model)} takes over below up_z {RECOVER_UP}, action scale "
f"{rec_scale}{' clipped to the joint limits' if rec_lo is not None else ''}, "
f"{f'joint targets <= {rec_qd:g} rad/s' if rec_qd else 'no joint target speed limit'} (F = shove, G = trip)")
# ── terrain ───────────────────────────────────────────────────────────────
print("[terrain] generating ...")
tparams = tp.TerrainParams()
tp.apply_terrain_preset(1, tparams) # Rolling Hills base shape
tparams.resolution = 192 # 192² cells — fast physics trimesh
tparams.world_size = WORLD_SZ
tparams.amplitude = float(args.amplitude)# physical height in metres (height_at must agree)
tparams.feature_scale = 45.0 # preset is 430 m for 1600 m world; scale to 80 m
tparams.octaves = 7 # more detail layers
tparams.warp = 0.40 # organic domain warp
tparams.falloff = tp.TerrainFalloff.Off# preset's radial bowl looks wrong at 80 m
tparams.erosion = tp.ErosionType.Off # thermal grooves are too fine at this scale
tparams.ao_strength = 8.0
gen = tp.TerrainGenerator(args.seed)
gen.build_field(tparams)
field = gen.get_field()
terr_tex = gen.bake_splat_texture(tparams)
terr_geo = build_terrain_zup(field, WORLD_SZ, float(args.amplitude))
# Sample terrain under the four feet + base to find the highest point in the
# footprint — prevents spawning any foot inside the mesh on sloped ground.
SPAWN_X, SPAWN_Y = -2.0, 0.0 # 2 m back from origin, facing +Y (90° CCW)
_FEET = ((0.30, 0.17), (0.30, -0.17), (-0.30, 0.17), (-0.30, -0.17), (0.0, 0.0))
h0 = max(float(gen.height_at(SPAWN_X + dx, SPAWN_Y + dy, tparams)) for dx, dy in _FEET)
print(f"[terrain] done h_footprint={h0:.3f} m")
# ── physics ───────────────────────────────────────────────────────────────
world = tp.PhysxWorld(gravity=tp.Vector3(0, 0, -9.81), fixed_timestep=0.002, max_substeps=20)
base = tp.Mesh(tp.BoxGeometry(WORLD_SZ + 10, WORLD_SZ + 10, 0.4), tp.MeshStandardMaterial())
base.position.set(0, 0, -0.2)
world.add_static(base)
terr_phys = tp.Mesh(terr_geo, tp.MeshStandardMaterial())
world.add_static_trimesh(terr_phys)
# The plant the policies trained on: real torque limits when the walking checkpoint says so (the course policies), and
# self-collision when a recovery policy is loaded (it trained with it on). Without them the viewer robot is stronger and
# its legs pass through its own body, which flatters a recovery (29 of 29 knock-overs got up that way, 2026-09-14).
_dlf = bool(_meta.get("drive_limits_are_forces", False)) or bool(args.recovery_model)
_selfc = bool(args.recovery_model) and bool(_rec_meta.get("self_collision", True))
if args.self_collision != "auto":
_selfc = args.self_collision == "on"
art, meshes = build_spot(world, assets, gains=STIFF_GAINS, # stiff gains (90) = base gait's plant
drive_limits_are_forces=_dlf, self_collision=_selfc)
print(f"[spot] plant: torque limits {'real' if _dlf else 'impulse (legacy)'}, self-collision {'on' if _selfc else 'off'}")
# Pushes are sized from the total mass. The bindings expose no articulation link mass, so this sums what build_spot built,
# link by link: each link's density is MASS / its collider's volume (box; capsule pi r^2 l + 4/3 pi r^3, the volumes PhysX
# integrates), so these are the masses PhysX computed.
_link_mass = [MASS["base"]] + [MASS[("hip", "uleg", "lleg")[(i - 1) % 3]] for i in range(1, len(art.links))]
robot_mass = float(sum(_link_mass))
print(f"[spot] total mass {robot_mass:.2f} kg over {len(_link_mass)} links: F shoves {SHOVE_DV:g} m/s "
f"({robot_mass * SHOVE_DV:.0f} N·s), G trips {TRIP_DV:g} m/s ({robot_mass * TRIP_DV:.0f} N·s at {TRIP_HEIGHT:g} m)")
def settle(n=80):
for _ in range(n):
art.set_drive_targets(default_q[add_to_isaac].astype(np.float32))
world.step(0.02)
_s = math.sqrt(2) / 2
_spawn_quat = tp.Quaternion(0.0, 0.0, _s, _s) # 90° CCW around Z
# set_joint_positions writes PhysX DOF slots (every hip, then every upper leg, then every knee), NOT the add order
# joint_positions() reads back. Learn the slot of each add-order joint once on the fresh robot rather than trust a
# layout (measured 2026-09-13: add index i -> slot [0,4,8,1,5,9,2,6,10,3,7,11]).
art.set_joint_positions((np.arange(12) + 1).astype(np.float32))
_dof_slot = np.rint(np.asarray(art.joint_positions())).astype(int) - 1
STANCE_Z = 0.505 # base height that puts the stance's foot tips 5 mm above flat ground (spot_slopes.STANCE_PRESET)