Skip to content

Commit af74189

Browse files
authored
Fix mobile gallery shadow flicker (#47)
* fix(renderer): clip low-angle shadow paths * fix(website): cap mobile gallery shadow reach --------- Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent ed5b245 commit af74189

3 files changed

Lines changed: 50 additions & 2 deletions

File tree

packages/polycss/src/api/createPolyScene.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2165,6 +2165,32 @@ describe("createPolyScene", () => {
21652165
expect((d.match(/Z/g) || []).length).toBe(1);
21662166
});
21672167

2168+
it("clips low-angle ground shadow path coordinates to the capped SVG box", () => {
2169+
scene = makeScene(host, {
2170+
textureLighting: "baked",
2171+
directionalLight: { direction: [1, 0, 0.01] },
2172+
shadow: { maxExtend: 20 },
2173+
});
2174+
scene.add(makeParseResult([sideTriangle()]), { castShadow: true, merge: false });
2175+
2176+
const shadow = host.querySelector(".polycss-shadow") as SVGSVGElement;
2177+
const width = Number(shadow.getAttribute("width"));
2178+
const height = Number(shadow.getAttribute("height"));
2179+
const d = shadow.querySelector("path")?.getAttribute("d") ?? "";
2180+
const values = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
2181+
2182+
expect(width).toBeGreaterThan(0);
2183+
expect(width).toBeLessThanOrEqual(40);
2184+
expect(height).toBeGreaterThan(0);
2185+
expect(values.length).toBeGreaterThan(0);
2186+
for (let i = 0; i < values.length; i += 2) {
2187+
expect(values[i]).toBeGreaterThanOrEqual(-0.001);
2188+
expect(values[i]).toBeLessThanOrEqual(width + 0.001);
2189+
expect(values[i + 1]).toBeGreaterThanOrEqual(-0.001);
2190+
expect(values[i + 1]).toBeLessThanOrEqual(height + 0.001);
2191+
}
2192+
});
2193+
21682194
it("baked mode projects every polygon (no Lambert cull) so thin/open meshes don't get silhouette holes", () => {
21692195
// backTriangle has its surface normal pointing AWAY from the
21702196
// default light. We deliberately do NOT cull these by Lambert

packages/polycss/src/api/createPolyScene.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1759,15 +1759,24 @@ export function createPolyScene(
17591759
const height = by1 - by0;
17601760
if (!(width > 0) || !(height > 0)) return false;
17611761

1762+
const clipBounds: Array<[number, number]> = [
1763+
[bx0, by0],
1764+
[bx1, by0],
1765+
[bx1, by1],
1766+
[bx0, by1],
1767+
];
17621768
let d = "";
17631769
for (const verts of polyProjections) {
1764-
const ccw = ensureCcw2D(verts);
1770+
const clipped = clipPolygonToConvex2D(ensureCcw2D(verts), clipBounds);
1771+
if (clipped.length < 3) continue;
1772+
const ccw = ensureCcw2D(clipped);
17651773
d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`;
17661774
for (let i = 1; i < ccw.length; i++) {
17671775
d += `L${(ccw[i]![0] - bx0).toFixed(3)},${(ccw[i]![1] - by0).toFixed(3)}`;
17681776
}
17691777
d += "Z";
17701778
}
1779+
if (!d) return false;
17711780
// (No receiver-footprint subtraction.) The earlier "cut every
17721781
// receiver's hull as a CW hole" approach broke fill-rule=nonzero
17731782
// wherever a receiver overlapped the caster's silhouette: a CCW

website/src/components/GalleryWorkbench/GalleryWorkbench.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ const LIGHT_HELPER_SELECTOR = ".dn-light-helper";
164164
const RESPONSIVE_ZOOM_BREAKPOINT = 900;
165165
const RESPONSIVE_ZOOM_BOTTOM_RESERVE = 72;
166166
const RESPONSIVE_ZOOM_MIN_SCALE = 0.42;
167+
const RESPONSIVE_SHADOW_EXTEND_BASE = 1600;
168+
const RESPONSIVE_SHADOW_EXTEND_MIN = 600;
167169

168170
function clamp(value: number, min: number, max: number): number {
169171
if (!Number.isFinite(value)) return min;
@@ -184,6 +186,15 @@ function responsiveZoomScaleForViewport(width: number, height: number): number {
184186
return clamp(Math.min(widthScale, heightScale), RESPONSIVE_ZOOM_MIN_SCALE, 1);
185187
}
186188

189+
function responsiveShadowMaxExtend(value: number, viewportScale: number): number {
190+
if (viewportScale >= 0.995) return value;
191+
const cap = Math.max(
192+
RESPONSIVE_SHADOW_EXTEND_MIN,
193+
Math.round(RESPONSIVE_SHADOW_EXTEND_BASE * viewportScale),
194+
);
195+
return Math.min(value, cap);
196+
}
197+
187198
function initialResponsiveZoomScale(): number {
188199
if (typeof window === "undefined") return 1;
189200
return responsiveZoomScaleForViewport(window.innerWidth, window.innerHeight);
@@ -806,10 +817,12 @@ export default function GalleryWorkbench() {
806817
const { handleCameraChange } = useGuiCameraSync({ setSceneOptions });
807818
const responsiveZoomScale = useResponsiveViewportZoomScale(viewportRef);
808819
const renderSceneOptions = useMemo<SceneOptionsState>(() => {
809-
if (responsiveZoomScale === 1) return sceneOptions;
820+
const shadowMaxExtend = responsiveShadowMaxExtend(sceneOptions.shadowMaxExtend, responsiveZoomScale);
821+
if (responsiveZoomScale === 1 && shadowMaxExtend === sceneOptions.shadowMaxExtend) return sceneOptions;
810822
return {
811823
...sceneOptions,
812824
zoom: sceneOptions.zoom * responsiveZoomScale,
825+
shadowMaxExtend,
813826
};
814827
}, [sceneOptions, responsiveZoomScale]);
815828
const handleRenderCameraChange = useCallback(

0 commit comments

Comments
 (0)