1
0
Fork 0

Shadow cast algorithm

This commit is contained in:
Pabloader 2026-05-04 15:57:53 +00:00
parent 3bc89af23b
commit 0077def410
4 changed files with 120 additions and 8 deletions

View File

@ -1,4 +1,5 @@
import { clamp } from "@common/utils";
import { shadowCast } from "./fov";
interface Point {
x: number;
@ -21,7 +22,7 @@ interface BresenhamOptions {
export interface BresenhamLineOptions extends BresenhamOptions { }
interface BresenhamCircleBaseOptions<T extends boolean | 'fov'> extends BresenhamOptions {
interface BresenhamCircleBaseOptions<T extends boolean | 'fov' | 'shadow'> extends BresenhamOptions {
/**
* - `false` (default) outline only.
* - `true` filled disc (span-from-outline scanline).
@ -31,7 +32,7 @@ interface BresenhamCircleBaseOptions<T extends boolean | 'fov'> extends Bresenha
}
type BresenhamCircleFillOptions = BresenhamCircleBaseOptions<boolean>;
interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov'> {
interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov' | 'shadow'> {
/**
* A callback that is called for each point on the ray casted from the start
* point to the current point. If the callback returns `true`, the ray is
@ -323,6 +324,14 @@ export function* bresenhamCircleGen(
return;
}
if (options?.fill === 'shadow') {
yield* shadowCast(x, y, r, (x, y) => {
if (!inBounds(x, y)) return true;
return options?.breaker?.(x, y) ?? false;
});
return;
}
if (fill) {
// Span-from-outline: use the midpoint algorithm to find the exact
// horizontal extent for each row, then fill between those extents.

View File

@ -0,0 +1,102 @@
export function* shadowCast(
x: number,
y: number,
r: number,
breaker: (x: number, y: number) => boolean
): Generator<{ x: number; y: number }> {
if (r < 0) return;
const radiusSq = r * r;
const seen = new Set<string>();
const keyOf = (px: number, py: number) => `${px},${py}`;
// Yield the source tile itself if it is not blocked.
if (!breaker(x, y)) {
seen.add(keyOf(x, y));
yield { x, y };
}
type Frame = {
octant: number;
row: number;
start: number;
end: number;
};
// 8 octants around the origin.
const OCTANTS: ReadonlyArray<readonly [number, number, number, number]> = [
[1, 0, 0, 1],
[0, 1, 1, 0],
[0, 1, -1, 0],
[1, 0, 0, -1],
[-1, 0, 0, -1],
[0, -1, -1, 0],
[0, -1, 1, 0],
[-1, 0, 0, 1],
];
const stack: Frame[] = [];
for (let octant = 7; octant >= 0; octant--) {
stack.push({ octant, row: 1, start: 1.0, end: 0.0 });
}
while (stack.length > 0) {
const frame = stack.pop()!;
if (frame.start < frame.end) continue;
const [xx, xy, yx, yy] = OCTANTS[frame.octant];
let start = frame.start;
let blocked = false;
let newStart = start;
for (let row = frame.row; row <= r; row++) {
let dx = -row - 1;
const dy = -row;
while (dx <= 0) {
dx++;
const mapX = x + dx * xx + dy * xy;
const mapY = y + dx * yx + dy * yy;
const leftSlope = (dx - 0.5) / (dy + 0.5);
const rightSlope = (dx + 0.5) / (dy - 0.5);
if (start < rightSlope) continue;
if (frame.end > leftSlope) break;
const opaque = breaker(mapX, mapY);
if (dx * dx + dy * dy <= radiusSq && !opaque) {
const key = keyOf(mapX, mapY);
if (!seen.has(key)) {
seen.add(key);
yield { x: mapX, y: mapY };
}
}
if (blocked) {
if (opaque) {
newStart = rightSlope;
continue;
}
blocked = false;
start = newStart;
} else if (opaque && row < r) {
blocked = true;
stack.push({
octant: frame.octant,
row: row + 1,
start,
end: leftSlope,
});
newStart = rightSlope;
}
}
if (blocked) break;
}
}
}

View File

@ -1,6 +1,6 @@
import { createCanvas } from "@common/display/canvas";
import { gameLoop } from "@common/game";
import { bresenhamCircleGen } from "@common/navigation/bresenham";
import { circleGen } from "@common/navigation/bresenham";
import Physics from "@common/physics";
import { mapNumber } from "@common/utils";
@ -162,7 +162,7 @@ const frame = (dt: number, state: State) => {
function fillCircle(ctx: CanvasRenderingContext2D, xc: number, yc: number, r: number) {
// because default circle fill is antialiased
for (const { x, y } of bresenhamCircleGen(xc, yc, r, { fill: true })) {
for (const { x, y } of circleGen(xc, yc, r, { fill: true })) {
ctx.fillRect(x, y, 1, 1);
}
}

View File

@ -3,6 +3,8 @@ import { bresenhamCircleGen } from "@common/navigation/bresenham";
const S = 20;
const breaker = (x: number, y: number) => Math.hypot(x - S * 0.3, y - S * 0.3) <= S / 16;
export default async function main() {
const canvas = createCanvas(S, S);
@ -10,12 +12,11 @@ export default async function main() {
if (!ctx) return;
ctx.fillStyle = 'black';
for (const fill of [true, false, 'fov'] as const) {
for (const fill of ['fov', 'shadow'] as const) {
let i = 0;
console.time(`fill=${fill}`);
// @ts-ignore
for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill })) {
for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill, breaker })) {
i++;
ctx.fillRect(x, y, 1, 1);
}