raycast برای ایجاد میدان دید بسیار کاربردیه.مثلا اگه شما پشت دیواری باشد دشمن شما رو نمی بینه ولی وقتی دیواری بین شما و دشمن نیست دشمن شما رو میبینه. خوب دشمن از خودش یه ray cast ایجاد میکنه که همیشه مقصد raycast به سمت شما یعنی پلیر باشه.وقتی که دیواری بین شما و دشمن باشه دشمن نمیتونه شما رو ببینه
تو این آموزش سعی میکنیم این کانسپت رو در زبان برنامه نویسی جاوااسکریپت پیاده کنیم و در نهایت به خروجی زیر خواهیم رسید:
هر یک از Ray ها میتونن در یک جهات تا بی نهایت ادامه داشته باشند. اگر یکی از این خط های ما به دیواری بخورد کرد ما باید الگوریتمی داشته باشیم که بتونیم برخورد Ray به دیوار رو تشخیص بدیم
و اگر این برخورد وجود داشت از نقطه ی شروع ray تا نقطه برخورد یک خط رسم میکنیم.
همونطور که در طرح نهایی مشاهده میکنید هر خط میتونه سایه هم داشته باشه برای رسم این سایه ها میتونیم بعد از نقطه ی برخورد خط به دیوار، خط سایه رو در جهت ray تا بینهایت ادامه بدیم تا در نهایت به اون افکت گیف بالا برسیم
دقت کنید اینجا هدف فقط توضیح نحوه ی پیاده سازی هست. مثلا کد های مربوط به رسم اشکال در canvas و... توضیح داده نمیشه تا مقاله طولانی نشه
خب برای شروع ابتدا یه فایل index.html
بسازید. برای پیاده سازی Raycasting ما نیاز به یک Canvas داریم
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Raycasting.js</title>
</head>
<body>
<canvas width="500" height="500"></canvas>
</body>
</html>
<script>/* scripts goes here */</script>
<style>
*,
*::after,
*::before {
padding: 0;
margin: 0;
}
canvas {
width: 500px;
height: 500px;
background: rgb(50, 50, 50);
}
</style>
ما کدهامون رو داخل تگ Script مینویسیم
اول باید یک رفرنس به canvas روی صفحه ایجاد کنیم
const canvas = document.querySelector("canvas")
const ctx = canvas.getContext("2d")
// canvas width and height
const x = 500
const y = 500
const createVector = (x, y) => {
return {
x: x,
y: y,
}
}
همچنین برای حرکت دادن منبع نور نیاز هست به mousemove روی canvas گوش بدیم
کلاس createVector کار خاصی نمیکنه فقط مقدار مختصات رو در یک فرمت بهتر بر میگردونه
const mouse = {
x: undefined,
y: undefined,
}
canvas.addEventListener("mousemove", function (event) {
mouse.x = event.x
mouse.y = event.y
})
همچنین برای اینکه بتونیم تغییرات روی canvas رو به صورت زنده و انیمیشن روی صفحه داشته باشیم، نیاز هست که در هر فریم محاسبات raycasting رو انجام بدیم فاکنشنی که به ما اجازه میده در هر فریم یک کار رو تکرار بکنیم requestAnimationFrame هست که اینجا از اون برای نمایش raycasting استفاده خواهیم کرد
const render = () => {
// codes for raycasting goes here
requestAnimationFrame(render)
}
requestAnimationFrame(render)
منبع نور جایی هست که ray های ما از اون جا ساطع خواهند شد برای اینکار میتونیم کلاسی به اسم Light تعریف کنیم
class Light {
constructor() {
this.pos = createVector(x / 2, y / 2)
}
}
ما میخوایم در شروع برنامه منبع نور ما در وسط صفحه باشه به خاطر همین x و y رو تفسیم بر 2 میکنیم.
همونطور که میبینید منبع نور ما یک دایره هست. یک فاکنشن در داخل کلاس ایجاد میکنیم برای رسم این دایره
class Light {
constructor() {
// --------
}
draw() {
ctx.beginPath()
ctx.arc(this.pos.x, this.pos.y, 10, 0, Math.PI * 2, false)
ctx.fillStyle = "green"
ctx.fill()
ctx.closePath()
this.rays.forEach((ray) => ray.draw())
}
}
خب حالا میتونیم یک ابجکت از کلاسمون ایجاد کنیم و متد draw رو در داخل requestAnimationframe صدا بزنیم
// --------------
const light = new Light()
const render = () => {
ctx.clearRect(0, 0, x, y) // clear canvas before next iteration
light.draw() // draw light source on the page
requestAnimationFrame(render)
}
requestAnimationFrame در هر فریم اجرا میشه و به همین علت ما نیاز داریم تا در هر فریم canvas رو پاک کنیم تا مقادیر قبلی روی صفحه نباشه
خب مثل تصویر زیر ما نیاز داریم که ray ها رو در یک زاویه 360 درجه دور منبع نورم بچینیم
برای اینکار، ابتدا کلاس Ray رو پیاده سازی میکنیم
class Ray {
constructor(pos, direction) {
this.pos = pos
this.dir = direction
}
draw() {
ctx.beginPath()
ctx.moveTo(this.pos.x, this.pos.y)
ctx.lineTo(this.pos.x + this.dir.x * 10, this.pos.y + this.dir.y * 10)
ctx.strokeStyle = "white"
ctx.stroke()
ctx.closePath()
}
cast() {
// implement later
}
}
هر Ray دارای 2 ورود هست:
و فانکشن draw
هم برای رسم این ray استفاده میشه
اگر به این خط دقت کنید:
ctx.lineTo(this.pos.x + this.dir.x * 10, this.pos.y + this.dir.y * 10)
مقدار this.pos.x + this.dir.x و this.pos.y + this.dir.y باعث میشه تا بتونیم جهت حرکت ray رو تعیین کنیم
همین فرمول باعث میشه بتونیم مقدار زیادی ray رو از زاویه صفر تا 360، دور منبع نور بچینیم
برای اینکار کلاس Light رو به صورت زیر تغییر میدیم
class Light {
constructor() {
this.pos = createVector(x / 2, y / 2)
this.rays = []
for (let i = 0; i < 360; i += 50) {
this.rays.push(new Ray(this.pos, createVector(Math.cos(i), Math.sin(i))))
}
}
draw() {
/* --- */
this.rays.forEach((ray) => ray.draw())
}
}
لیست ray ها رو در ارایه this.rays
اضافه میکنیم. همونطور که میدونیم دایره دارای زاویه 360 درجه هست به همین علت نیاز به حلقه for داریم که از 0 تا این زاویه حرکت کنه و در هر حرکت موقعیت ray و زاویه اون رو مشخص کنه برای تعیین direction و درجه حرکت ray میتونیم از:
Math.cos
Math.sin
استفاده کنیمدرباره ی نمودار های سینوسی و کسینوسی میتونید توی اینترنت بیشتر بخونید کافیه همینقدر بتونید که این 2 تا به ما کمک میکنن دور یک دایره ray های خودمون رو قرار بدیم
بعد از اینکه ray هامون رو ساختمون در متد draw منبع سورس اون رو رندر میکنیم
اگر تا اینجا با من پیش اومده باشید باید همچین صحنه رو روی مانیتور خودتون ببینید
حالا وقت به حرکت در آوردن این منبع نور هست. اگر یادتون باشه در ابتدای آموزش ما یک event lister به canvas اصافه کردیم تا به تغییرات موس روی Canvas گوش کنیم
ما میدونیم که منبع نور ما یک موقعیت (X,Y) روی صفحه مختصات داره. برای همین اگر ما این اعداد رو تغییر بدیم قاعدتا منبع نور ما هم باید تغییر کنه
پس به کلاسی نیاز داریم تا مختصات موس رو بخونه و اون رو به منبع نور ما اضافه کنه
class Light {
constructor() {
/*
-------
*/
}
look(mouseX, mouseY) {
this.pos.x = mouseX
this.pos.y = mouseY
}
draw() {
/*
-------
*/
}
}
const render = () => {
/* --- */
light.draw()
light.look(mouse.x, mouse.y)
/* --- */
}
همونطور که میبینید فانکشن look موقعیت موس رو میگیره و اون رو به منبع نور اضافه میکنه. برای اینکه ما این تغییرات رو در صورت حرکت موس ببینیم، نیاز هست که متد look
رو داخل requestAnimationFrame
صدا بزنیم
ابتدا کلاس دیوار رو اضافه میکنیم
class Wall {
constructor(x1, y1, x2, y2) {
this.p_start = createVector(x1, y1)
this.p_end = createVector(x2, y2)
}
draw() {
ctx.beginPath()
ctx.moveTo(this.p_start.x, this.p_start.y)
ctx.lineTo(this.p_end.x, this.p_end.y)
ctx.strokeStyle = "white"
ctx.stroke()
ctx.closePath()
}
}
در کلاس Wall
مختصات شروع و پایان یک خط رو میگیریم و اون رو در canvas رندر میکنیم
ما میخوایم تا با هربار رفرش برنامه موقعیت دیوار رو تغییر بدیم. برای اینکار، یک حلقه for تغریف میکنیم تا 8 تا دیوار به صورت رندوم برای ما تولید کنه
این کد رو در بالای فانکشن render اضافه میکنیم و در داخل اون این دیوار ها رو رندر میکنیم
const walls = []
for (let i = 0; i < 8; i++) {
const x1 = Math.floor(Math.random() * x)
const y1 = Math.floor(Math.random() * y)
const x2 = Math.floor(Math.random() * x)
const y2 = Math.floor(Math.random() * y)
walls.push(new Wall(x1, y1, x2, y2))
}
// animations goes here
const render = () => {
ctx.clearRect(0, 0, x, y)
light.draw()
walls.forEach((wall) => wall.draw()) // draw walls
light.look(mouse.x, mouse.y)
requestAnimationFrame(render)
}
با هر بار رفرش صفحه ما طرح مختلفی از دیوار ها رو خواهیم داشت
خب حالا به مرحله جالب این پروژه میرسیم، تشخیص اینکه آیا ray با دیوار برخورد کرده یا نه
برای اینکه این بخش رو متوجه بشید حتما پیشنهاد میکنم نگاهی به این صفحه ویکیپدیا بزنید
فرض کنید ما نقاط شروع و پایان هر دو خط رو داریم برای اینکه حساب کنیم آیا این 2 خط با هم برخورد میکنن یا نه نیاز هست مشتق این 2 خط رو حساب کنیم
پس داخل کلاس Ray نیاز به یک فانکشن جدید داریم
class Ray {
constructor(pos, direction) {
/*
-----
*/
}
draw() {
/*
-----
*/
}
cast(wall) {
// first line segment
const x1 = wall.p_start.x
const y1 = wall.p_start.y
const x2 = wall.p_end.x
const y2 = wall.p_end.y
// ray line segment
const x3 = this.pos.x
const y3 = this.pos.y
const x4 = this.pos.x + this.dir.x
const y4 = this.pos.y + this.dir.y
const den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if (den == 0) {
return
}
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / den
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / den
if (t > 0 && t < 1 && u > 0) {
const pt = createVector(x1 + t * (x2 - x1), y1 + t * (y2 - y1))
return pt
}
}
}
از مقدار x1 تا x4 مقدار ورودی برای 2 خط ما هستند. همچنین y1 تا y4
در خط بعدی ما denominator یا مشتق این 2 خط رو حساب میکنیم
const den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if (den == 0) {
return
}
اگر den
برابر با صفر باشه یعنی این خطوط موازی هستند و هیچوقت برخورد نخواهند کرد
اما اگر موازی نباشند ما نیاز داریم تا t و u این خطوط رو حساب کنیم
در فرمول ریاضی داریم: اگر t و u بین 0 و 1 باشند، یعنی برخورد بین 2 خط وجود دارد
پس ابتدا t و u را حساب میکنیم و بعد برسی میکنیم که آیا برخورد دارند یا نه
اگر برخورد وجود داشت حالا باید چک کنیم که نقطه برخورد این دو خط کجاست
خوشبختانه برای اینکار هم فرمول وجود داره
if (t > 0 && t < 1 && u > 0) {
// x = x1 + t * (x2 - x1)
// y = y1 + t * (y2 - y1)
const pt = createVector(x1 + t * (x2 - x1), y1 + t * (y2 - y1))
return pt
}
پس از اضافه کردن این کد ها، الان هم لیست wall ها رو داریم و هم لیست rays
حالا باید بررسی کنیم اگر برخوردی وجود داشت از نقطه شروع ray تا wall خط رو رسم کنیم
برای اینکار متد cast رو داخل کلاس Light اضافه میکنیم. این متد وظیفه صدا زدن cast
برای همه ی ray ها رو بر عهده داره
این متد به عنوان ورودی لیست دیوار هایی که قبلا ساختیم رو میگیره
class Light {
constructor() {}
look(mouseX, mouseY) {}
draw() {}
cast(walls) {
for (let i = 0; i < this.rays.length; i++) {
const ray = this.rays[i]
for (let j = 0; j < walls.length; j++) {
const pt = ray.cast(walls[j])
if (pt) {
ctx.beginPath()
ctx.moveTo(this.pos.x, this.pos.y)
ctx.lineTo(pt.x, pt.y)
ctx.strokeStyle = "green"
ctx.stroke()
ctx.closePath()
}
}
}
}
}
const render = () => {
/* ----- */
light.cast(walls) // cast light to walls in requestAnimationFrame
/* ---- */
}
همونطور که میبینید ما داخل متد cast لیست دیوار ها رو به عنوان ورودی اضافه میکنیم
نکته ای که اینجا وجود داره این هست که هر Ray باید با همه ی دیوار ها چک بشه و اگر برخوردی وجود داشت یک خط از منبع نور تا دیوار رسم کنه.
خروجی کد بالا به صورت زیر خواهد بود:
مشکلی که اینجا به وجود اومد این هست که ممکنه یک Ray با چندتا دیوار برخورد داشته باشه و با توجه به کد ما برنامه به همه ی اون دیوار هاخط رسم میکنه
اما فرض کنید یک دیوار پشت یه دیوار دیگه قرار داشته باشه. در این صورت نباید به دیوار پشتی Ray رسم بشه
برای اینکار ما باید وقتی که Ray رو به سمت دیوار میفرستیم باید فاصله طی شده توسط Ray رو ذخیره کنیم. و فقط Ray رو به دیواری ارسال کنیم که کمترین فاصله رو با منبع نور ما داره
پس کد بالا رو باید به صورت زیر تغییر بدیم
for (let i = 0; i < this.rays.length; i++) {
const ray = this.rays[i]
const closest = {
dist: Infinity,
pt: undefined,
}
for (let j = 0; j < walls.length; j++) {
const pt = ray.cast(walls[j])
if (pt) {
const dist = Math.sqrt(Math.pow(pt.x - this.pos.x, 2) + Math.pow(pt.y - this.pos.y, 2))
if (dist < closest.dist) {
closest.dist = dist
closest.pt = pt
}
}
}
if (closest.pt) {
ctx.beginPath()
ctx.moveTo(this.pos.x, this.pos.y)
ctx.lineTo(closest.pt.x, closest.pt.y)
ctx.strokeStyle = "green"
ctx.stroke()
ctx.closePath()
}
}
ابتدا، ما یک آبجکت ایجاد میکنیم تا نزدیکترین نقطه به منبع نور رو ذخیره کنیم
const closest = {
dist: Infinity,
pt: undefined,
}
در هر مرحله که Ray رو به سمت یک دیوار میفرستیم، بررسی میکنیم آیا فاصله بین 2 نقطه از فاصله ی ذخیره شده کمتره یا نه، در صورت کمتر بودن فاصله جدید رو ذخیره میکنیم
const dist = Math.sqrt(Math.pow(pt.x - this.pos.x, 2) + Math.pow(pt.y - this.pos.y, 2))
if (dist < closest.dist) {
closest.dist = dist
closest.pt = pt
}
فرمول پیدا کردن 2 نقطه در جدول مختصات هم در اینترنت موجوده با یه سرچ ساده میتونید راجبش بخونید
خب، حالا که Ray رو به همه ی دیوار ها ارسال کردیم و کمترین فاصله رو داخل ابجکت closest
ذخیره کردیم، وقت اونه که خط رو از مبدا نور به نزدیک ترین دیوار رسم کنیم
for (let i = 0; i < this.rays.length; i++) {
/* rays loop */
for (let j = 0; j < walls.length; j++) {
/* closest distance */
}
if (closest.pt) {
// line
ctx.beginPath()
ctx.moveTo(this.pos.x, this.pos.y)
ctx.lineTo(closest.pt.x, closest.pt.y)
ctx.strokeStyle = "green"
ctx.stroke()
ctx.closePath()
}
}
بعد از اعمال تغییرات بالا میبینید که مشکل قبلی حل شد
برای مرحله آخر از این پروژه میتونیم به دیوار هایی که Ray بهشون برخورد میکنه، در پشت سرشون سایه ایجاد کنیم
این مرحله خیلی راحته چرا که ما نقطه برخورد خط ها رو داریم کافیه از اون نقطه تا بی نهایت یک خط رسم کنیم
if (closest.pt) {
// line
ctx.beginPath()
ctx.moveTo(this.pos.x, this.pos.y)
ctx.lineTo(closest.pt.x, closest.pt.y)
ctx.strokeStyle = "green"
ctx.stroke()
ctx.closePath()
// ray shadow
ctx.beginPath()
ctx.moveTo(closest.pt.x, closest.pt.y)
ctx.lineTo(closest.pt.x + ray.dir.x * 9999, closest.pt.y + ray.dir.y * 9999)
ctx.strokeStyle = "rgba(0,0,0,0.3)"
ctx.stroke()
ctx.closePath()
}
تبریک میگم! ما تونستیم با هم یک سیستم raycasting رو در فضای 2 بعدی با زبان جاوااسکریپت پیاده سازی کنیم
امیدوارم این آموزش براتون مفید بوده باشه
اگر جایی مشکل داشتید میتونید سورس کد این پروژه رو در گیتهاب مشاهده کنید