Raycaster z PixiJS i kolizjami

Oto jak zaimplementowałem mój system Raycastingu w PixiJS:

przez: jonathan.lepage

11.11.2018

Obrazek autorstwa: jonathan.lepage

ECSGAMEDEVMATHPIXIJSTS

Implementacja Raycastera w PixiJS z Wykrywaniem Kolizji Vertex

/blogs/article-2/b2-0.gif

Wprowadzenie

Podjąłem się sporego wyzwania: zaimplementowania kluczowej funkcji do prawidłowego zarządzania kolizjami w moim silniku gry.

W przeciwieństwie do większości silników gier, które używają wektorów, PixiJS obsługuje jedynie bounds w postaci prostokątnych rzutów.
O ile nie stanowi to problemu w 2D, o tyle to podejście jest bardzo ograniczone w kontekście potrzeb gry.
Powyższy obrazek ilustruje zachowanie raycasta na bounds naszych obiektów.


Problem

Jednakże w 3D to podejście stanowi znaczący problem.
Gdy DisplayObject obraca się, jego bounds pozostaje prostym, statycznym prostokątem.

Oto przykład:



Dlatego musiałem poświęcić kilka dni na studiowanie matematyki i trygonometrii, aby zaimplementować tę funkcjonalność.

Ta funkcja będzie kluczowa, aby aktor mógł postrzegać swoje otoczenie i wchodzić w interakcje z obiektami w zależności od ich odległości i wysokości.
Ten raycast posłuży również systemowi FOW (Fog of War), wyświetlając określone części mapy i elementy w zależności od wykrytych kolizji.

Jest to temat, który poruszam jedynie pobieżnie, ponieważ jest bardzo złożony.
Mam jednak nadzieję, że ten artykuł zainspiruje Cię do własnego projektu.



Integracje

Aby uprościć moje podejście, stworzyłem Class Helper w ramach mojej architektury.
Te Class Helper są przechowywane w plikach `.h.ts` i eksportują wyłącznie obiekty Pixi.

ts
export class RayCasterHelper extends Container3d {
	public rays:Sprite3d[] = [];
	public raysVirtual:Graphics[] = [];
	public raysDistance:number[] = [];
	public boundsDebugs:Graphics[] = [];
	public raysTextDistance:Text3d[] = [];
	public rayLength:number = 900;
	public raySize:number = 12;
	public inCircleRadius:number = 10;
	public quadrans:number = 8;
	public shapes:CollisionShape[] = [];
}


Inicjalizacja tworzy liczbę segmentów wskazaną w parametrze.
W moim przypadku kwadrant składający się z 8 segmentów okazał się więcej niż wystarczający.
Taka konfiguracja ułatwia również debugowanie, ponieważ pozwala na przypisanie 8 różnych kolorów do tych segmentów.

ts
public initialize( renderer:Renderer ) {
		this.clear();
		const colors = [0x00ff00, 0xff0000, 0x0000ff, 0xffff00, 0x00ffff, 0xff00ff, 0x333444, 0xffffff];
		for ( let i = 0, l = this.quadrans; i < l; i++ ) {
			const color = colors[i % colors.length];
			const edges = [
				new Point( this.inCircleRadius, 0 ),
				new Point( this.inCircleRadius + this.rayLength, 0 ),
			];
			const ray = new Graphics();
			ray.lineStyle( this.raySize, color, 1 );
			ray.moveTo( this.inCircleRadius, 1 );
			ray.drawPolygon( edges );
			ray.endFill();

			const raySprite = new Sprite3d( renderer.generateTexture( ray ) );
			this.rays.push( raySprite );
			this.shapes[i] = new CollisionShape( raySprite, edges );

			raySprite.proj.euler.z = ( Math.PI * 2 / this.quadrans ) * i;
			this.raysDistance.push( 0 );


Warto zauważyć tworzenie punktów, które służą jako segmenty.
Normalnie, potrzebne byłyby tylko punkty przekątnych, ponieważ prostokąt nie ma martwego punktu na osiach poziomej i pionowej.
Możliwe jest więc ich ignorowanie w zależności od procentu kwadrantu, co pozwala pominąć promienie pionowe i poziome, aby generować poligony tylko na przekątnych.

ts
for ( let i = 0, l = this.quadrans; i < l; i++ ) {
			const color = colors[i % colors.length];

			// continue if is horizontal or vertical
			if( i % 2 === 0 ) continue;
			const edges = [...


Następnie pojawia się metoda intersect, która akceptuje w parametrach wyłącznie DisplayObject.
Dostarczamy jej wszystkie DisplayObject, na których raycast będzie musiał iterować.

ts
public intersects( worlds:Container3d[] ) {
		const { rays, quadrans, intersectedMap, intersectedObjectsMap, closestMap } = this;

		intersectedMap.clear();
		intersectedObjectsMap.clear();
		closestMap.clear();

		for ( let i = 0, l = quadrans; i < l; i++ ) {
			const ray = rays[i];
			const b1 = ray.getBounds( true, this._cacheRectangle1 );
			for ( const object of worlds ) {
				const b2 = object.getBounds( true, this._cacheRectangle2 );
				const intersect = intersects( b1, b2 );
				if ( intersect ) {
					const rayshape = this.shapes[i];
					if ( rayshape.intersectsShape( b2 ) ) {
						intersectedMap.set( i, ( intersectedMap.get( i ) ?? [] ).concat( object ) );
						intersectedObjectsMap.set( object, ( intersectedObjectsMap.get( object ) ?? [] ).concat( i ) );
					}
				}
			}
		}


Musiałem również dostosować funkcję z PixiJS, aby wykrywać przecięcia i zbierać dodatkowe dane.
Kod przedstawiono poniżej; nie różni się on znacząco od oryginalnej metody.

ts
/** return the intersection between two rectangles if any */
function intersects( source:Rectangle, other:Rectangle ) {
	let x0_1 = source.x < other.x ? other.x : source.x;
	let x1_1 = source.right > other.right ? other.right : source.right;
	if ( x1_1 <= x0_1 ) {
		return null;
	}
	let y0_1 = source.y < other.y ? other.y : source.y;
	let y1_1 = source.bottom > other.bottom ? other.bottom : source.bottom;
	if ( y1_1 <= y0_1 ) {
		return null;
	}

	// Compute the intersection distance
	const dx = x0_1 - x1_1;
	const dy = y0_1 - y1_1;
	const distance = Math.sqrt( dx * dx + dy * dy );

	// Return the intersection details
	return {
		x: x0_1,
		y: y0_1,
		x2: x1_1,
		width: x1_1 - x0_1,
		height: y1_1 - y0_1,
		distance: distance,
		inRatioXY: Math.abs( dx / dy ),
	};
}


Wreszcie, gdy Pixi wykryje przecięcia między dwoma obiektami, możliwe jest dopracowanie lokalnego wykrywania, aby sprawdzić, czy nasze ukośne segmenty dotykają obiektów.


Notatka dotycząca optymalizacji:

Obiekty środowiskowe wykorzystują klasyczne kwadratowe bounds.
Rzeczywiście, przy wielu tysiącach obiektów, wykrywanie kolizji oparte na wszystkich segmentach bez zoptymalizowanej struktury prowadziłoby do bardzo poważnych problemów z wydajnością.
Prawdopodobnie lepiej byłoby użyć web workers lub shaders, aby zoptymalizować ten typ obliczeń, zamiast zlecać je do zarządzania CPU.

Dlatego tylko przekątne DisplayObject raycastera będą obsługiwać wykrywanie kolizji poligonalnych, oferując w ten sposób 50% wzrost precyzji.
Aby osiągnąć 100% precyzji, obiekty środowiskowe musiałyby również być poligonami, co nie jest niezbędne dla mojego obecnego silnika.
Z wyjątkiem być może ukośnych ogrodzeń, które potencjalnie mogłyby stanowić problem...
przyszłość pokaże.

Ta metoda IntersectsShape będzie musiała więc iterować po każdym z punktów vertex, aby sprawdzić, czy te punkty znajdują się wewnątrz bounds DisplayObject.

ts
intersectsShape( rec:Rectangle ) {
		const edges = this.edges;

		for ( let i = 0; i < edges.length; i++ ) {
			const edge = edges[i];
			const point = edge.intersectsRectangle( rec );
			if ( point ) {
				return true;
			}
		}

		return false;
	}

class Segment {
	public p1: Point;
	public p2: Point;
	constructor( p1 = new Point(), p2 = new Point() ) {
		this.p1 = p1;
		this.p2 = p2;
	}

	intersects( edge: Segment, asSegment = true, point = new Point() ) {
		const a = this.p1;
		const b = this.p2;
		const e = edge.p1;
		const f = edge.p2;

		const a1 = b.y - a.y;
		const a2 = f.y - e.y;
		const b1 = a.x - b.x;
		const b2 = e.x - f.x;

		const c1 = ( b.x * a.y ) - ( a.x * b.y );
		const c2 = ( f.x * e.y ) - ( e.x * f.y );
		const denom = ( a1 * b2 ) - ( a2 * b1 );

		if ( denom === 0 ) {
			return null;
		}

		point.x = ( ( b1 * c2 ) - ( b2 * c1 ) ) / denom;
		point.y = ( ( a2 * c1 ) - ( a1 * c2 ) ) / denom;

		if ( asSegment ) {
			const uc = ( ( f.y - e.y ) * ( b.x - a.x ) - ( f.x - e.x ) * ( b.y - a.y ) );
			const ua = ( ( ( f.x - e.x ) * ( a.y - e.y ) ) - ( f.y - e.y ) * ( a.x - e.x ) ) / uc;
			const ub = ( ( ( b.x - a.x ) * ( a.y - e.y ) ) - ( ( b.y - a.y ) * ( a.x - e.x ) ) ) / uc;

			if ( ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1 ) {
				return point;
			} else {
				return null;
			}
		}

		return point;
	}

	intersectsRectangle( rect: Rectangle ) {
		const rectEdges = [ //TODO: will need optimize with a cache pool of points
			new Segment( new Point( rect.left, rect.top ), new Point( rect.right, rect.top ) ),
			new Segment( new Point( rect.right, rect.top ), new Point( rect.right, rect.bottom ) ),
			new Segment( new Point( rect.right, rect.bottom ), new Point( rect.left, rect.bottom ) ),
			new Segment( new Point( rect.left, rect.bottom ), new Point( rect.left, rect.top ) )
		];

		for ( const rectEdge of rectEdges ) {
			if ( this.intersects( rectEdge, true ) ) {
				return true;
			}
		}
		return false;
	}
}


Końcowy render


Metoda segmentIntersects to ta, w której wykonywane są najbardziej złożone obliczenia.
Uruchomienie jej zajęło mi dwa dni intensywnej pracy, ale wynik jest bardzo zadowalający.
Jak widać na tym gifie, mogę teraz wchodzić w interakcje z moim otoczeniem i przypisywać tagi oraz informacje dzięki Raycastingowi.

To proste narzędzie oferuje wiele interesujących funkcji do wykorzystania.