Shadow cast algorithm
This commit is contained in:
parent
3bc89af23b
commit
0077def410
|
|
@ -1,4 +1,5 @@
|
||||||
import { clamp } from "@common/utils";
|
import { clamp } from "@common/utils";
|
||||||
|
import { shadowCast } from "./fov";
|
||||||
|
|
||||||
interface Point {
|
interface Point {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -21,7 +22,7 @@ interface BresenhamOptions {
|
||||||
|
|
||||||
export interface BresenhamLineOptions extends 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.
|
* - `false` (default) — outline only.
|
||||||
* - `true` — filled disc (span-from-outline scanline).
|
* - `true` — filled disc (span-from-outline scanline).
|
||||||
|
|
@ -31,7 +32,7 @@ interface BresenhamCircleBaseOptions<T extends boolean | 'fov'> extends Bresenha
|
||||||
}
|
}
|
||||||
|
|
||||||
type BresenhamCircleFillOptions = BresenhamCircleBaseOptions<boolean>;
|
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
|
* 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
|
* point to the current point. If the callback returns `true`, the ray is
|
||||||
|
|
@ -323,6 +324,14 @@ export function* bresenhamCircleGen(
|
||||||
return;
|
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) {
|
if (fill) {
|
||||||
// Span-from-outline: use the midpoint algorithm to find the exact
|
// Span-from-outline: use the midpoint algorithm to find the exact
|
||||||
// horizontal extent for each row, then fill between those extents.
|
// horizontal extent for each row, then fill between those extents.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createCanvas } from "@common/display/canvas";
|
import { createCanvas } from "@common/display/canvas";
|
||||||
import { gameLoop } from "@common/game";
|
import { gameLoop } from "@common/game";
|
||||||
import { bresenhamCircleGen } from "@common/navigation/bresenham";
|
import { circleGen } from "@common/navigation/bresenham";
|
||||||
import Physics from "@common/physics";
|
import Physics from "@common/physics";
|
||||||
import { mapNumber } from "@common/utils";
|
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) {
|
function fillCircle(ctx: CanvasRenderingContext2D, xc: number, yc: number, r: number) {
|
||||||
// because default circle fill is antialiased
|
// 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);
|
ctx.fillRect(x, y, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { bresenhamCircleGen } from "@common/navigation/bresenham";
|
||||||
|
|
||||||
const S = 20;
|
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() {
|
export default async function main() {
|
||||||
const canvas = createCanvas(S, S);
|
const canvas = createCanvas(S, S);
|
||||||
|
|
||||||
|
|
@ -10,12 +12,11 @@ export default async function main() {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
ctx.fillStyle = 'black';
|
ctx.fillStyle = 'black';
|
||||||
for (const fill of [true, false, 'fov'] as const) {
|
for (const fill of ['fov', 'shadow'] as const) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
console.time(`fill=${fill}`);
|
console.time(`fill=${fill}`);
|
||||||
// @ts-ignore
|
for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill, breaker })) {
|
||||||
for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill })) {
|
|
||||||
i++;
|
i++;
|
||||||
ctx.fillRect(x, y, 1, 1);
|
ctx.fillRect(x, y, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue