This repository was archived by the owner on Aug 14, 2026. It is now read-only.
forked from TeJota1337/DramaticShapeVoxelMod
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathVoxel3D.lua
More file actions
1605 lines (1522 loc) · 73.8 KB
/
Copy pathVoxel3D.lua
File metadata and controls
1605 lines (1522 loc) · 73.8 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
-- Voxel world mode: the 3D pass -- shader, depth buffer and camera.
--
-- World space is world PIXELS, so every coordinate the 2D paths already
-- compute drops straight in with no unit conversion:
--
-- +X map east (world-pixel x)
-- +Y up (0 is the ground plane)
-- +Z map south (world-pixel y)
--
-- A character at rest faces +Z, i.e. toward a camera parked to the south,
-- which is what "facing down" means in the 2D game -- and a character card
-- is drawn in exactly that pose, leaning back rather than yawing.
--
-- The camera orbits the view centre at Voxel.angle: 0 is straight down
-- (what the flat 2D view already is) and 50 degrees leans toward the
-- horizon. Distance and field of view are tied to Voxel.FOCAL, which is the
-- same constant Tilt projects with, so a given angle frames the world
-- identically in both modes -- switching between them changes the geometry,
-- not the framing.
--
-- Every GPU object is pcall-guarded and `available()` reports the result:
-- headless test runs and any driver without depth-canvas support fall back
-- to the existing tilt/flat paths rather than erroring.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local ViewCull = V.require("ViewCull")
local Sky = V.require("Sky")
local DayNight = V.require("DayNight")
local GlassMask = V.require("GlassMask")
local PixelCanvas = V.require("PixelCanvas")
local GraphicsSettings = V.require("GraphicsSettings")
local Voxel3D = {}
-- Vertex format shared by terrain chunks and character models: a position,
-- the map-canvas / sprite-sheet pixel it samples, and a per-vertex darken
-- factor that gives a face its angle to the sun without a normal or a
-- light uniform. Cast shadows are a separate thing entirely -- see
-- ShadowMap, which the pixel shader below samples on top of this.
Voxel3D.FORMAT = {
{ "VertexPosition", "float", 3 },
{ "VertexTexCoord", "float", 2 },
{ "VertexShade", "float", 1 },
}
-- Face shading by direction id: top faces stay
-- full brightness, sides step down so an extruded block reads as solid
-- instead of a flat sticker, and the faces turned away from the sun are
-- darkest. The sun hangs in the SOUTHEAST (see ShadowMap), so south and
-- east are the lit flanks and north and west the shaded ones -- east and
-- west used to share one value back when the sun sat due northwest and the
-- two were symmetric about it.
--
-- This is still worth baking even now that the shadow pass throws real
-- shadows: a face turned away from the sun is dark because of its ANGLE,
-- which no shadow map measures, and the two compound the way they should
-- -- an away-facing wall that is also occluded goes darker still.
Voxel3D.FACE_SHADE = {
[1] = 0.84, -- +X east (toward the sun)
[2] = 0.72, -- -X west (away)
[3] = 1.00, -- +Y up
[4] = 0.55, -- -Y down
[5] = 0.90, -- +Z south (toward the camera, and toward the sun)
[6] = 0.68, -- -Z north (away)
}
local SHADER = [[
varying float vShade;
varying vec3 vSun; // this fragment's place in the sun's view
varying float vFog; // how deep into the map's haze it stands
#ifdef VOXEL_CULL
// where this fragment stands in the FLAT world, for the diorama's
// viewport to measure. Same precision reasoning as vGrid below: a
// route's coordinates run to a few thousand and mediump has no
// fraction left out there, which would make the rim crawl.
varying LOVE_HIGHP_OR_MEDIUMP vec3 vWorld;
#endif
#ifdef VOXEL_GRID
// model space, one unit per voxel -- see VoxelGrid. Precision matters
// here in a way it does not for a colour: the seam is the FRACTIONAL
// part of a coordinate that runs to a few thousand across a big route,
// so a mediump varying would quantise the fraction away entirely.
varying LOVE_HIGHP_OR_MEDIUMP vec3 vGrid;
#endif
#ifdef VERTEX
uniform mat4 vp;
uniform mat4 model;
uniform mat4 sunModel; // where the SUN sees this vertex (see below)
uniform mat4 sunVP; // world -> the shadow map's unit cube
uniform vec3 eye;
uniform float pull;
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
uniform vec4 fogInfo; // density, start, heightK; density 0 = clear
attribute float VertexShade;
vec4 position(mat4 transform_projection, vec4 vertex_position) {
vShade = VertexShade;
#ifdef VOXEL_GRID
// MODEL space, deliberately: every mesh here is built a unit per
// voxel in its own frame, so the seams ride the model however it is
// posed rather than the world's grid sliding across a leaning sprite
vGrid = vertex_position.xyz;
#endif
vec4 w = model * vertex_position;
// The shadow lookup runs off `sunModel`, not `model`. For terrain the
// two are the same matrix, but a character is drawn as a slab LEANING
// back by the camera's pitch -- a trick played on the viewer, which
// the sun never saw: it lit the upright card. Looking up with the
// leaned position asks whether the sun reached a place the figure is
// not, and since the lean tips the body north and shadows now fall
// north, every sprite's own card fell across its front. Looking up
// with the card's position asks the question the sun actually
// answered. (The pull below is excluded for the same reason: it is a
// depth trick aimed at the camera's own buffer.)
vSun = (sunVP * (sunModel * vertex_position)).xyz;
// THE MAP'S HAZE (see ForestAtmos): how much fog stands between the
// eye and this vertex -- distance dissolves into it, altitude climbs
// out of it. Worked out on the FLAT world like the shadow lookup
// above (the curve is a trick played on the viewer, not weather),
// and per VERTEX: on meshes built a face per voxel the interpolated
// answer is indistinguishable from per-fragment fog at a fraction of
// the cost.
vFog = 0.0;
if (fogInfo.x > 0.0) {
float fogRun = max(0.0, length(w.xyz - eye) - fogInfo.y);
vFog = (1.0 - exp(-fogInfo.x * fogRun))
* exp(-max(w.y, 0.0) * fogInfo.z);
}
#ifdef VOXEL_CULL
// THE DIORAMA'S VIEWPORT (see lib/Diorama) is measured per FRAGMENT,
// so this stage's only job is to hand the position over -- and to hand
// over the FLAT one, like the fog and the shadow lookup above: the
// curve is a trick played on the viewer, and letting it drag geometry
// in and out of the viewport would make the rim breathe with the bend.
//
// Per fragment rather than per vertex because the diorama's own base
// is cut into cells far coarser than the rim is wide, and interpolating
// the rim across one of those spilled a whole cell of ground past the
// edge of a staged fight's disc.
vWorld = w.xyz;
#endif
// The curved world (see WorldCurve): drop every vertex by the square
// of how far its column stands from the camera's focus. Applied AFTER
// the shadow lookup above and clear of the wireframe's model space, so
// both are worked out on the flat world and the bend carries them
// along -- which is why neither has to know this exists. Along Y only,
// so a column moves as one piece: the world tips away and the
// buildings standing on it stay upright.
if (curve.z > 0.0) {
vec2 cd = w.xz - curve.xy;
w.y -= dot(cd, cd) * curve.z;
}
// camera-ward pull: move the vertex along ITS OWN ray to the eye.
// This is a pure depth bias -- the projection of a point moved along
// its eye ray is bit-identical, so there is no screen drift at all.
// (An earlier CPU version translated along the central view axis,
// which preserved only the screen centre and made off-centre sprites
// and grass swim against the ground while the camera scrolled.)
if (pull > 0.0) {
w.xyz += normalize(eye - w.xyz) * pull;
}
return vp * w;
}
#endif
#ifdef PIXEL
#ifdef VOXEL_CULL
// The viewport, declared in THIS STAGE ALONE. A uniform declared in both
// defaults to highp in the vertex stage and mediump here, and GLSL ES
// refuses to link a uniform the two stages disagree about -- which is
// not a broken cut but no scene shader at all (lib/Water states the same
// trap at length for `vp`).
uniform vec3 cullAt; // the viewport's centre, in world pixels
uniform vec3 cullShape; // half-size, 1/fade, kind: 1 box, 2 ball,
// 3 the staged fight's pillar
// 1 well inside the viewport, 0 outside it, and the rim in between --
// which is a HARD edge for the box (its band is half a pixel wide, so
// the ramp is just the antialiasing) and a dissolve for the other two.
//
// The box and the pillar are unbounded upward and downward on purpose:
// what is wanted is a square (or round) piece cut OUT OF THE MAP, and a
// cut with a lid would take the tops off the trees standing in it.
float dioramaCull(vec3 p) {
if (cullShape.z <= 0.5) return 1.0;
vec3 cd = p - cullAt;
float d;
if (cullShape.z < 1.5) {
d = max(abs(cd.x), abs(cd.z)); // the box: a square of map
} else if (cullShape.z < 2.5) {
d = length(cd); // the ball, under V-CURVE
} else {
d = length(cd.xz); // the fight's pillar
}
return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0);
}
#endif
uniform Image sunMap;
uniform Image sunActorMap;
uniform float sunActorOn;
uniform float sunDark; // how far into black a shadow goes; 0 = off
uniform float sunBias;
uniform vec2 sunTexel;
uniform float sunSoft; // 1 = four taps, 0 = one mobile-friendly tap
// the two-channel pack ShadowMap writes: high byte, then low
float sunDepth(vec2 uv) {
vec4 c = Texel(sunMap, uv);
return c.r + c.g * (1.0 / 255.0);
}
float sunActorDepth(vec2 uv) {
vec4 c = Texel(sunActorMap, uv);
return c.r + c.g * (1.0 / 255.0);
}
// 1.0 in full sun, 1.0 - sunDark in full shadow. Four taps half a texel
// out on the diagonals: a 2x2 box filter, which is what turns the
// shadow map's texel staircase into a one-pixel soft edge.
float sunlight(vec3 p) {
if (sunDark <= 0.0) return 1.0;
// outside the sun's frustum nothing was recorded, so nothing occludes
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) {
return 1.0;
}
// Ease the shadows off at the frustum's rim. The map covers the ground
// the camera can see out to a cap, and past the low rungs -- 75 degrees
// especially -- the horizon is further than any box worth paying for.
// Without this the covered region simply ENDS, drawing a hard line
// across the middle distance where every shadow stops at once; with it
// the far field just loses them, which reads as distance.
vec2 e = min(p.xy, 1.0 - p.xy);
float edge = smoothstep(0.0, 0.06, min(e.x, e.y));
if (edge <= 0.0) return 1.0;
float z = p.z - sunBias;
// Moving characters are stored separately so their tiny layer can update
// every frame while the route-sized terrain map remains world-anchored.
// One nearest actor tap is enough: sprite silhouettes are pixel cutouts,
// while the expensive four-tap softness remains on the terrain edges.
float actorLit = mix(1.0, step(z, sunActorDepth(p.xy)), sunActorOn);
if (sunSoft < 0.5) {
float lit = min(step(z, sunDepth(p.xy)), actorLit);
return 1.0 - sunDark * edge * (1.0 - lit);
}
float lit = step(z, sunDepth(p.xy + sunTexel * vec2(-0.5, -0.5)))
+ step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, -0.5)))
+ step(z, sunDepth(p.xy + sunTexel * vec2(-0.5, 0.5)))
+ step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, 0.5)));
return 1.0 - sunDark * edge
* (1.0 - min(lit * 0.25, actorLit));
}
#ifdef VOXEL_GRID
uniform float gridDark; // how far toward black a seam pulls; 0 = off
uniform float gridWidth; // seam width, in display pixels
// How much of this fragment a voxel seam covers, 0 to 1.
float voxelSeam(vec3 p) {
// how much of `p` this fragment spans on screen, per axis: the
// conversion from model units to display pixels, measured rather than
// derived, so it holds under any camera pitch or zoom
vec3 w = fwidth(p);
vec3 d = abs(fract(p + 0.5) - 0.5); // distance to the nearest plane
// The axis a face does not vary along is that face's own normal, and
// its distance is a constant zero -- take it at face value and every
// face floods solid. Push those axes out of reach instead of dividing
// by their zero.
vec3 live = step(1e-4, w);
vec3 px = d / max(w, vec3(1e-6)) + (1.0 - live) * 1e6;
float near = min(min(px.x, px.y), px.z);
// Fade out where a voxel is too small to hold a line. Survey zoom
// draws a world pixel at about a display pixel, and a wall seen nearly
// edge-on squashes one to nothing at any zoom -- either way the seams
// land closer together than they are wide, and drawn anyway they stop
// being a wireframe and become a flat 45% dimming of the whole scene.
// The tightest axis decides, which is the honest test of whether the
// grid can be resolved at all.
float span = 1.0 / max(max(w.x, max(w.y, w.z)), 1e-6);
float fade = clamp((span - 2.0) * 0.5, 0.0, 1.0);
// the textbook antialiased line: solid within the half-width, fading
// over the one pixel outside it
return fade * clamp(gridWidth * 0.5 + 0.5 - near, 0.0, 1.0);
}
#endif
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform vec3 fogColor; // what the haze is made of (see Voxel3D.fog)
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
uniform float glassPhase; // the glint's phase: advances with TRAVEL
uniform float glassGlint; // and its strength: 0 while standing still
uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass)
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
// sprite sheets key GB OBJ color 0 to alpha 0; discarding rather than
// blending keeps those texels out of the depth buffer, so a model never
// carves a transparent hole out of whatever stands behind it
if (p.a < 0.5) discard;
// and the same for anything the diorama's viewport has faded out
// entirely: past the rim there is no world, and a fully faded fragment
// that still wrote depth would punch a hole in the sky behind it
#ifdef VOXEL_CULL
float cull = dioramaCull(vWorld);
if (cull <= 0.0) discard;
#else
float cull = 1.0;
#endif
// the hour's tint multiplies like the sun terms do: it is LIGHT, the
// same warm or moonlit cast on every surface, not a palette swap
vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
#ifdef VOXEL_GRID
// darken what is there rather than painting a colour, so a seam across
// dark grass and one across a white roof each stay in their own palette
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
#endif
// WINDOW GLASS, marked per atlas texel by the mask (see GlassMask).
// By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES
// -- the phase is fed by the camera's own travel and the strength dies
// within a beat of standing still, because a reflection is something
// the viewpoint does: still camera, still glass. It lifts the texel
// toward sky-white and leaves the art visible through it. After dark
// the pane is LIT: the texel's own shine pattern carried into a warm
// lamp colour, replacing the shaded answer above -- so a lit window
// ignores the sun, every shadow and the hour's tint, exactly as a
// window with a lamp behind it does.
// glassOn gates the whole thing per DRAW: the mask is shaped like the
// tileset atlas, and only meshes textured FROM that atlas may consult
// it -- a character samples its own sprite sheet, whose coordinates
// land on the mask's pane rectangles by accident and would stripe the
// cast with lamplight at night.
float glass = Texel(glassMask, tc).a * glassOn;
if (glass > 0.0) {
// the sweep lives in the PANE's own space (atlas texels), not the
// screen's: a pattern anchored to the screen has the world sliding
// through it at zoom speed whenever the camera pans, which strobed --
// worst where the pan and the phase ran opposite ways. Anchored to
// the glass, panning moves nothing; only the phase does, a fraction
// of a texel per step, the same in every walking direction.
float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase);
float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint;
vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass);
float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114));
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
rgb = mix(pane, lamp, glassNight * glass);
}
// the haze stands between the eye and the SURFACE, so it lands after
// every surface term -- sun, seams, glass -- and before only the
// ghost, which must stay one solid readable shape whatever the
// weather (see below)
rgb = mix(rgb, fogColor, vFog);
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
// detail; replacing the colour outright is what makes it read as one
// solid silhouette. Last in the chain, so neither the sun nor a voxel
// seam can mottle it.
rgb = mix(rgb, ghostColor, ghost);
// the viewport's rim is an ALPHA, so the last of the model blends into
// whatever the frame opened with -- the sky, or the chroma key. 1
// everywhere without the cut compiled in, which is every flat frame.
return vec4(rgb, cull) * color;
}
#endif
]]
-- Compilations of SHADER, by what is compiled INTO it: the voxel
-- wireframe, and the diorama's viewport. Variants rather than branches,
-- for two different reasons.
--
-- The wireframe needs shader derivatives (fwidth), the one piece of this a
-- driver can refuse, so a refusal has to cost the grid and nothing else.
--
-- The viewport carries a world-position varying, and a varying is paid for
-- by every fragment of every frame whether or not anything reads it. The
-- cut only ever exists inside a headset's diorama, so every other frame --
-- the flat screen, and a phone above all -- compiles and binds exactly
-- what it always did.
--
-- Each entry is nil = untried, false = unavailable.
local shaders = {}
local function shaderKey(grid, cull)
return (grid and "grid" or "plain") .. (cull and "+cull" or "")
end
local activeShader = nil -- the variant this pass bound
-- Scene canvases, one per NAMED SLOT. There are exactly two callers and
-- they want different sizes -- the free-roam pass renders at the window's
-- pixel dimensions, the overworld battle at the GB's 160x144 -- and a
-- single cached canvas made every battle entry and exit reallocate one.
-- A slot reallocates only when its OWN size changes, which is a window
-- resize, so the pair is stable for a session.
local slots = {}
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
local held = nil -- and the whole record for it
local active = false
-- A READABLE depth canvas, so a later pass in the same frame can ask the
-- buffer questions rather than only write to it -- which is the whole of
-- what makes screen-space reflections possible (see Water).
--
-- `depth = true` in the target list, which is what this used to bind,
-- allocates an internal depth buffer that is written and tested and can
-- never be sampled. An explicit canvas is the same buffer with a texture
-- handle on it, and costs the same memory.
--
-- nil where the driver will not make one -- every depth format is optional
-- in GLES and a canvas is the only honest test of any of them, so this asks
-- for several in order of preference: 24 bits, the same 24 riding a stencil
-- (a pairing some mobile drivers will texture when the bare format they
-- refuse), 32-bit float, and 16 as the floor every GLES3 device can read.
-- Refused all four, beginScene falls straight back to the internal buffer,
-- which is exactly the old behaviour minus the reflections.
local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
local function newDepth(w, h)
if not (love.graphics and love.graphics.newCanvas) then return nil end
local c = nil
for _, format in ipairs(DEPTH_FORMATS) do
local ok, made = pcall(love.graphics.newCanvas, w, h,
{ format = format, readable = true })
if ok and made then c = made break end
end
if not c then return nil end
-- nearest: a depth is a distance, and a blend of two of them is a
-- distance to nothing. The march wants the texel it landed on.
pcall(c.setFilter, c, "nearest", "nearest")
pcall(c.setWrap, c, "clamp", "clamp")
-- and no compare mode: with one set, Texel returns a 0/1 shadow verdict
-- instead of the depth, which is not what any reader here wants
pcall(c.setDepthSampleMode, c)
return c
end
-- The bound target for the slot this pass holds: the colour canvas plus
-- either the readable depth canvas or the internal buffer.
local function depthTarget()
if held and held.depth then
return { held.canvas, depthstencil = held.depth }
end
return { canvas, depth = true }
end
-- Every GPU object one slot owns. The mirror is the copy of the frame the
-- water pass reads (see beginWater); it is only ever made if something asks
-- for one, so a session that never sees a lake never pays for it.
local function releaseSlot(slotHeld)
for _, key in ipairs({ "canvas", "depth", "mirror" }) do
local obj = slotHeld[key]
if obj and obj.release then pcall(obj.release, obj) end
slotHeld[key] = nil
end
end
local IDENTITY = Mat4.identity()
-- Whether the driver admits to supporting derivatives. Only a hint --
-- the compile below is the real test -- but it saves building a shader
-- that was never going to work, and it is how LOVE reports the ES2
-- extension the grid rides on.
local function derivativesOK()
if not (love.graphics and love.graphics.getSupported) then return false end
local ok, caps = pcall(love.graphics.getSupported)
return ok and caps and caps.shaderderivatives == true
end
-- The scene shader. `grid` asks for the wireframe variant and `cull` for
-- the diorama's viewport; nil comes back when that combination will not
-- build -- callers then fall back to a plainer one rather than losing the
-- whole 3D pass.
function Voxel3D.shader(grid, cull)
grid, cull = grid and true or false, cull and true or false
local key = shaderKey(grid, cull)
if shaders[key] == nil then
if grid and not derivativesOK() then
shaders[key] = false
else
local src = (grid and "#define VOXEL_GRID 1\n" or "")
.. (cull and "#define VOXEL_CULL 1\n" or "") .. SHADER
local ok, sh = pcall(love.graphics.newShader, src)
shaders[key] = ok and sh or false
end
end
return shaders[key] or nil
end
-- Whether the 3D path can run at all. False on a headless test run (no
-- love.graphics), without shader support, or where a depth canvas cannot be
-- created -- every caller treats that as "stay on the 2D path".
function Voxel3D.available()
if not (love.graphics and love.graphics.newCanvas
and love.graphics.setDepthMode) then
return false
end
return Voxel3D.shader() ~= nil
end
-- Build a mesh in the shared format. `verts` is the LOVE vertex list and
-- `map` the triangle index list. Returns nil when meshes are unavailable,
-- which the callers treat the same way they treat a missing model.
function Voxel3D.newMesh(verts, map)
if #verts == 0 then return nil end
local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.FORMAT, verts,
"triangles", "static")
if not ok then return nil end
if map and #map > 0 then pcall(mesh.setVertexMap, mesh, map) end
return mesh
end
-- The quad corner offsets and UV corners for one face direction, in the
-- order the vertex map below stitches into two triangles. Corners are unit
-- offsets from the voxel's (x, y, z) minimum corner.
Voxel3D.FACE_CORNERS = {
[1] = { { 1, 0, 0 }, { 1, 0, 1 }, { 1, 1, 1 }, { 1, 1, 0 } }, -- +X
[2] = { { 0, 0, 1 }, { 0, 0, 0 }, { 0, 1, 0 }, { 0, 1, 1 } }, -- -X
[3] = { { 0, 1, 0 }, { 1, 1, 0 }, { 1, 1, 1 }, { 0, 1, 1 } }, -- +Y
[4] = { { 0, 0, 1 }, { 1, 0, 1 }, { 1, 0, 0 }, { 0, 0, 0 } }, -- -Y
[5] = { { 0, 0, 1 }, { 1, 0, 1 }, { 1, 1, 1 }, { 0, 1, 1 } }, -- +Z
[6] = { { 1, 0, 0 }, { 0, 0, 0 }, { 0, 1, 0 }, { 1, 1, 0 } }, -- -Z
}
-- Append the six indices of quad `n` (0-based) to a triangle index list.
function Voxel3D.pushQuad(map, n)
local b = n * 4
map[#map + 1] = b + 1
map[#map + 1] = b + 2
map[#map + 1] = b + 3
map[#map + 1] = b + 1
map[#map + 1] = b + 3
map[#map + 1] = b + 4
end
-- ---------------------------------------------------------------- camera --
-- An explicit camera, replacing the orbit below for as long as it is set:
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil,
-- up = {x,y,z} or nil }.
--
-- A caller with matrices of its own -- the VR eyes, whose view comes from
-- a tracked pose and whose projection is an off-centre frustum no
-- eye/focus/fov triple can express -- sets `view` and `proj` instead, and
-- the eye/focus fields stay for everything that reasons about the camera
-- rather than projecting with it (setLook, the sky, the water's lean).
--
-- The orbit is the free-roam camera and it is described entirely by ONE
-- number, the pitch, because that is all a camera following the player over
-- their own map ever needs. A staged shot -- the overworld battle's
-- over-the-shoulder rig (see BattleCam) -- is a placed camera: it has a yaw,
-- it does not sit above its focus, and its framing comes from the arena
-- rather than from the view size. Rather than widen the orbit into
-- something that could express both and be the wrong shape for each, a
-- caller with a camera of its own simply hands it over.
--
-- Everything downstream is unchanged by this: the shader uniforms, project()
-- and the overlay all read Voxel3D.vp / Voxel3D.eye, which are set the same
-- way either way.
Voxel3D.camera = nil
-- This frame's camera RAY FAN, set by viewProjection alongside vp: the
-- world direction a canvas point looks along (see Sky.paint's `ray`).
-- Present for every free-pitch camera -- the VR eyes bring theirs
-- (VRRig.eyeCamera), a placed eye/focus camera gets one built -- and nil
-- for the orbit, whose frame-hung sky is the classic look.
Voxel3D.skyRayLive = nil
-- ------- which way, and how steeply, this camera looks
--
-- Two facts about the view direction, set alongside the eye and the focus
-- because they ARE the eye and the focus, and read by anything that has to
-- reason about the camera's ATTITUDE rather than about a point in front of
-- it:
--
-- lookFlat the view direction flattened onto the ground plane and
-- normalized -- "the way the horizon lies from here", which is
-- what a reflection leans toward at the steeper rungs (Water).
-- descent how far below horizontal the view runs, as a sine: 0 looking
-- level, 1 looking straight down. It is the number that says
-- whether there is a horizon in frame at all, and it answers
-- the same way for the orbit and for a placed battle camera --
-- which is why this is derived from the two vectors rather than
-- read off Voxel.angle, a rung the battle camera does not have.
--
-- A camera looking exactly straight down has no horizontal direction at all,
-- and lookFlat then keeps whatever it last held rather than becoming a zero
-- vector nothing downstream could normalize.
Voxel3D.lookFlat = { 0, 0, -1 }
Voxel3D.descent = 0
local function setLook(eye, focus)
local dx = focus[1] - eye[1]
local dy = focus[2] - eye[2]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
if len < 1e-6 then return end
Voxel3D.descent = math.max(0, math.min(1, -dy / len))
local flat = math.sqrt(dx * dx + dz * dz)
if flat < 1e-6 then return end
Voxel3D.lookFlat = { dx / flat, 0, dz / flat }
end
-- View and projection for a `vw` x `vh` world-pixel view centred on
-- (cx, cy) in world pixels. Returns the combined matrix.
function Voxel3D.viewProjection(cx, cy, vw, vh)
local cam = Voxel3D.camera
if cam then
local eye, focus = cam.eye, cam.focus
Voxel3D.eye = eye
-- kept beside the eye for horizonY: where the sky's pale end goes is a
-- question about which way this camera looks, and only these two answer it
Voxel3D.focus = focus
setLook(eye, focus)
-- a camera that brought its own matrices (a VR eye) projects with
-- them; only the clip-space Y flip is added, for the same canvas
-- reason as every other branch here
if cam.view and cam.proj then
Voxel3D.fovY = cam.fov
-- the VR eyes bring their fan with them (VRRig.eyeCamera)
Voxel3D.skyRayLive = cam.skyRay
return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view)
end
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
-- kept for the passes that measure an ANGLE against this camera rather
-- than a position: the water's reflected sun is sized in radians, and
-- radians per canvas pixel is exactly this over the frame height
Voxel3D.fovY = cam.fov
local proj = Mat4.perspective(cam.fov, vw / vh,
math.max(1, dist * 0.05), dist * 4 + 4096)
-- the same clip-space Y flip the orbit needs, for the same reason: we
-- bypass LOVE's transform_projection and canvas coordinates run Y down
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
-- The camera's RAY FAN, for the sky's skybox path (Sky.paint's `ray`):
-- a placed camera with a FREE PITCH -- the first-person rig, steered
-- by a mouse on the flat screen -- must not hang its gradient off the
-- frame, or looking up and down drags the bands with the view. Built
-- from the very basis the view below is: forward, the true right, the
-- true up, and the symmetric frustum's tangents.
local upv = cam.up or { 0, 1, 0 }
local fx, fy, fz = -dx / dist, -dy / dist, -dz / dist
local crx = fy * upv[3] - fz * upv[2]
local cry = fz * upv[1] - fx * upv[3]
local crz = fx * upv[2] - fy * upv[1]
local crl = math.sqrt(crx * crx + cry * cry + crz * crz)
if crl > 1e-6 then
crx, cry, crz = crx / crl, cry / crl, crz / crl
local cux = cry * fz - crz * fy
local cuy = crz * fx - crx * fz
local cuz = crx * fy - cry * fx
local tanY = math.tan(cam.fov / 2)
local tanX = tanY * (vw / vh)
Voxel3D.skyRayLive = {
base = { fx - crx * tanX + cux * tanY,
fy - cry * tanX + cuy * tanY,
fz - crz * tanX + cuz * tanY },
du = { crx * 2 * tanX, cry * 2 * tanX, crz * 2 * tanX },
dv = { cux * -2 * tanY, cuy * -2 * tanY, cuz * -2 * tanY },
}
else
Voxel3D.skyRayLive = nil
end
-- world up by default, so the horizon stays level -- a placed camera
-- that rolled with its own pitch would tip the whole arena. A caller
-- may hand its own up: the first-person BLEND does, because its far
-- end is the orbit, whose up leans with the pitch -- world up at the
-- orbit's steep end degenerates against a straight-down view.
return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 }))
end
-- the orbit: a fixed pitch per rung, and the classic frame-hung sky --
-- no ray fan wanted
Voxel3D.skyRayLive = nil
local a = Voxel.angle
local focal = Voxel.FOCAL
local dist = focal * vh
-- the FOV that makes a straight-down camera at `dist` frame exactly `vh`
-- world pixels, which is the framing the flat view already has
local fov = 2 * math.atan(1 / (2 * focal))
Voxel3D.fovY = fov
local focus = { cx, 0, cy }
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
Voxel3D.eye = eye
Voxel3D.focus = focus
setLook(eye, focus)
-- perpendicular to the view direction in the YZ plane: north is screen-up
-- when looking straight down, +Y is screen-up when looking level. Never
-- parallel to the view direction, so there is no degenerate a = 0 case.
local up = { 0, math.sin(a), -math.cos(a) }
local proj = Mat4.perspective(fov, vw / vh,
math.max(1, dist * 0.05), dist * 4 + 4096)
-- Flip clip-space Y. Mat4.perspective emits textbook GL clip space with
-- +Y up, but we bypass LOVE's own transform_projection, and LOVE's canvas
-- coordinates run Y DOWN -- so without this the entire scene composites
-- vertically mirrored: north at the bottom and buildings extruding
-- downward. Winding flips with it, which is free here because the pass
-- draws with culling off.
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
end
-- ------- the horizon
--
-- Where the ground plane's vanishing line lands, in canvas pixels down from the
-- top edge, or nil when this camera has no horizon to find.
--
-- Not a fraction picked by eye. A direction ALONG the ground is a point at
-- infinity, and putting one through the same matrix the geometry is drawn with
-- gives the line every ground plane in the scene converges on -- so the sky's
-- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides
-- the camera tween instead of having to be retuned against it.
--
-- The world CURVE is not in it, and cannot be: it bends distant ground down in
-- the vertex shader, so the ground's apparent edge sits BELOW this line by
-- however much the bend took. What shows in between is the haze the sky's fill
-- already is, which is what a curved-away horizon should look like.
--
-- nil in two cases, both meaning "no horizon in this frame": a camera looking
-- straight down, whose forward direction has no horizontal part to send to
-- infinity, and one whose vanishing line is behind it.
function Voxel3D.horizonY(h)
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
if not (m and eye and focus and h and h > 0) then return nil end
local dx = focus[1] - eye[1]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dz * dz)
if len < 1e-6 then return nil end
dx, dz = dx / len, dz / len
-- a DIRECTION, so its w is zero and the matrix's translation column drops
-- out; the clip-space Y flip is already baked into m, so this comes out in
-- canvas coordinates rather than needing one
local y = m[5] * dx + m[7] * dz
local w = m[13] * dx + m[15] * dz
if w <= 1e-6 then return nil end
return (y / w * 0.5 + 0.5) * h
end
-- The horizon as a LINE rather than a row, for a camera that can ROLL --
-- a VR eye. A head tipped sideways tips the true horizon across the
-- canvas, and a sky painted in flat rows then visibly hinges with the
-- head. So: project the flat forward direction (a point ON the vanishing
-- line) and the same direction nudged a hair of world-up (a point just
-- above it); the difference is the canvas direction "down toward the
-- ground", perpendicular to the horizon however the head is tipped.
--
-- Returns (ax, ay, edge, top): a unit axis in canvas pixels pointing from
-- sky toward ground, the horizon's signed distance along it -- a pixel at
-- canvas (x, y) is above the horizon while x*ax + y*ay < edge -- and,
-- when `elev` (radians) is given, the distance the direction that far
-- ABOVE the horizon projects to. `top` is what pins the gradient's far
-- end to a real direction in the sky: extrapolating it linearly from a
-- pixels-per-radian estimate left the bands sliding as a pitch moved the
-- horizon through the frame, because a perspective's rows are tan-spaced,
-- not angle-spaced. nil `top` (the elevated direction is outside this
-- frustum's forward hemisphere) leaves the caller its estimate. nil
-- everything with no horizon in front of this camera.
function Voxel3D.horizonLine(w, h, elev)
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
if not (m and eye and focus and w and h and h > 0) then return nil end
local dx = focus[1] - eye[1]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dz * dz)
if len < 1e-6 then return nil end
dx, dz = dx / len, dz / len
local function proj(vx, vy, vz)
local x = m[1] * vx + m[2] * vy + m[3] * vz
local y = m[5] * vx + m[6] * vy + m[7] * vz
local ww = m[13] * vx + m[14] * vy + m[15] * vz
if ww <= 1e-6 then return nil end
return (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h
end
local qx, qy = proj(dx, 0, dz)
if not qx then return nil end
local rx, ry = proj(dx, 0.02, dz)
if not rx then return nil end
local ax, ay = qx - rx, qy - ry
local al = math.sqrt(ax * ax + ay * ay)
if al < 1e-6 then ax, ay = 0, 1 else ax, ay = ax / al, ay / al end
local top = nil
if elev then
local ce, se = math.cos(elev), math.sin(elev)
local tx, ty = proj(dx * ce, se, dz * ce)
if tx then top = tx * ax + ty * ay end
end
return ax, ay, qx * ax + qy * ay, top
end
-- ------- the hour's light
--
-- What the scene shader multiplies every surface by (see dayTint in the
-- shader). Set per pass by whoever knows what map is being drawn --
-- VoxelScene for free-roam, BattleScene for the arena -- because "is this
-- outdoors" is the map's question, not this pass's. Neutral until somebody
-- answers it, so a caller that never does draws exactly what it always drew.
Voxel3D.tint = { 1, 1, 1 }
-- The map's haze, set the same way (VoxelScene and BattleScene ask
-- ForestAtmos, who knows which maps have weather): a table of
-- { color = {r,g,b}, density, start, heightK }, or nil for a clear day.
-- nil -- the default -- sends density 0, so a caller that never heard of
-- fog draws exactly what it always drew, and no pass can inherit the
-- last one's weather.
Voxel3D.fog = nil
-- THE DIORAMA'S VIEWPORT, set the same way (VoxelScene asks lib/Diorama,
-- who is told by lib/VR what the headset is doing): a table of
-- { x, y, z, r, invFade, kind }, kind 1 for the ball and 2 for the staged
-- fight's pillar. nil -- the default, and what every flat frame leaves it
-- at -- sends kind 0, which is the shader's "draw the whole world".
--
-- A plain field rather than a require of lib/Diorama, and deliberately:
-- this file is the bottom of the stack and everything else in the mode is
-- built on it, so it learns about the diorama the same way it learns about
-- the weather and the hour -- by being handed the answer.
Voxel3D.cull = nil
-- What the background is cleared to INSTEAD of the sky, or nil for the
-- sky: DIORAMA-MR's chroma key, set for the eye passes alone.
Voxel3D.keyColor = nil
-- The window-glass pass, set the same way and for the same reason: the
-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the
-- panes are belongs to the hour and to being outdoors at all
-- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect.
Voxel3D.glassMask = nil
Voxel3D.glassNight = 0
-- the glint, fed by the camera's TRAVEL rather than by a clock (see
-- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and
-- the strength is 0 whenever the view has been still for a beat
Voxel3D.glassPhase = 0
Voxel3D.glassGlint = 0
-- The sun or moon disc's place on this camera's canvas, or nil when the
-- body is set, on the southern half of the sky, or behind the camera.
--
-- The direction comes from DayNight (true bearing, squashed elevation) and
-- goes through the SAME matrix the geometry is drawn with, as a point at
-- infinity -- exactly how horizonY finds the vanishing line. So the disc's
-- azimuth is honest: it stands over the point on the horizon its shadows
-- point away from, at every pitch, fov, window shape and zoom.
--
-- Must run after beginScene has set Voxel3D.vp for this frame's camera.
function Voxel3D.skyBody(w, h)
local m = Voxel3D.vp
local b = m and DayNight.body()
if not b then return nil end
local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz
local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz
local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz
if ww <= 1e-6 then return nil end
local amt, color = DayNight.glow()
return {
x = (x / ww * 0.5 + 0.5) * w,
y = (y / ww * 0.5 + 0.5) * h,
-- the body's WORLD direction, for the skybox path: a ray-fan caller
-- measures the twilight glow by the angle between a pixel's ray and
-- this, so the glow is pinned to the sky like the bands are (see
-- Sky.paint's glowDir)
dx = b.dx, dy = b.dy, dz = b.dz,
moon = b.moon,
glowAmt = amt,
glowColor = color,
}
end
-- ------- the VR sky's world-anchored pieces
--
-- Both exist because a headset showed the shortcuts: a gradient painted
-- off the frame moved with the head that carried the frame, and a
-- screen-space disc re-snapped its cell grid with every head movement
-- and held its face square to the canvas instead of to the world. The
-- gradient's fix rides the camera record itself (skyRay -- see VRRig and
-- Sky's useRay path); the disc's is below.
-- The sun or moon as a QUAD IN THE WORLD: the baked cell art
-- (Sky.discImage) on a square spanned about the hour's direction, its
-- corners projected through this very eye -- so the disc is pinned to
-- the sky like the terrain is to the ground, stable under every head
-- motion, its face upright over the world. Runs inside beginScene's sky
-- window, before the depth mode is set, so the world draws over it.
local discMesh = nil
local function drawWorldDisc(w, h)
local b = DayNight.body()
if not (b and b.dy and b.dy > 0.005) then return end
local amt = DayNight.glow()
local img = Sky.discImage(b.moon, Sky.discLooming(amt, b.moon))
if not img then return end
local m = Voxel3D.vp
if not m then return end
local hl = math.sqrt(b.dx * b.dx + b.dz * b.dz)
if hl < 1e-6 then return end
-- right = horizontal, perpendicular to the direction; up completes it
local rx, rz = b.dz / hl, -b.dx / hl
local ux = -rz * b.dy
local uy = rz * b.dx - rx * b.dz
local uz = rx * b.dy
local ul = math.sqrt(ux * ux + uy * uy + uz * uz)
if ul < 1e-6 then return end
ux, uy, uz = ux / ul, uy / ul, uz / ul
if uy < 0 then ux, uy, uz = -ux, -uy, -uz end
-- apparent size is an ANGLE, the same fraction of the view the flat
-- screen's disc takes of its frame; the low sun looms exactly as there
local ang = Sky.DISC_FRAC * (Voxel3D.fovY or 1)
if Sky.discLooming(amt, b.moon) then ang = ang * 1.4 end
local k = math.tan(ang)
local verts = {}
local corners = { { -1, -1, 0, 1 }, { 1, -1, 1, 1 },
{ 1, 1, 1, 0 }, { -1, 1, 0, 0 } }
for i, c in ipairs(corners) do
local vx = b.dx + (rx * c[1] + ux * c[2]) * k
local vy = b.dy + (uy * c[2]) * k
local vz = b.dz + (rz * c[1] + uz * c[2]) * k
local x = m[1] * vx + m[2] * vy + m[3] * vz
local y = m[5] * vx + m[6] * vy + m[7] * vz
local ww = m[13] * vx + m[14] * vy + m[15] * vz
if ww <= 1e-6 then return end
verts[i] = { (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h,
c[3], c[4] }
end
pcall(function()
if not discMesh then
discMesh = love.graphics.newMesh(4, "fan", "stream")
end
discMesh:setVertices(verts)
discMesh:setTexture(img)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(discMesh)
end)
end
-- ----------------------------------------------------------------- scene --
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
-- (cx, cy), covering `vw` x `vh` world pixels. Returns false when the pass
-- could not start, in which case the caller must not call endScene.
-- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the
-- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the
-- void transparent, which is what every rung below it wants.
-- `slot` names which cached canvas to render into (see `slots` above);
-- omitted is the free-roam world pass.
function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- the wireframe variant when the player has it on AND it built, and the
-- viewport variant while a diorama frame is open; either answer falls
-- through to a plainer scene rather than to no scene. The cut is dropped
-- LAST, because losing it draws a whole uncut world where a model should
-- be, which is worse than losing the seams.
local grid = VoxelGrid.enabled()
local cut = Voxel3D.cull ~= nil
local sh = grid and Voxel3D.shader(true, cut) or nil
if not sh then
grid = false
sh = Voxel3D.shader(false, cut)
end
if not sh and cut then
cut, sh = false, Voxel3D.shader(false, false)
end
if not sh then return false end
local name = slot or "world"
local slotHeld = slots[name]
if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then
local ok, c = PixelCanvas.new(w, h)
if not ok then return false end
c:setFilter("nearest", "nearest")
if slotHeld then releaseSlot(slotHeld) end
-- the depth canvas is sized with its colour, so a window resize
-- reallocates the pair together and they can never disagree
slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) }
slots[name] = slotHeld
end
held = slotHeld
canvas, canvasW, canvasH = held.canvas, w, h
-- a depth buffer is what makes occlusion real: walk behind a building and
-- the building wins, with no y-sorting anywhere
local ok = pcall(love.graphics.setCanvas, depthTarget())
if not ok and held.depth then
-- the readable canvas would not bind; fall back to the internal buffer
-- for the rest of this session rather than losing the whole 3D pass
pcall(held.depth.release, held.depth)
held.depth = nil
ok = pcall(love.graphics.setCanvas, depthTarget())
end
if not ok then
pcall(love.graphics.setCanvas)
return false
end