بازگشت

پیاده سازی raycasting در جاوااسکریپت

raycasting in javascript

Raycasting چیست؟

raycast برای ایجاد میدان دید بسیار کاربردیه.مثلا اگه شما پشت دیواری باشد دشمن شما رو نمی بینه ولی وقتی دیواری بین شما و دشمن نیست دشمن شما رو میبینه. خوب دشمن از خودش یه ray cast ایجاد میکنه که همیشه مقصد raycast به سمت شما یعنی پلیر باشه.وقتی که دیواری بین شما و دشمن باشه دشمن نمیتونه شما رو ببینه

تو این آموزش سعی میکنیم این کانسپت رو در زبان برنامه نویسی جاوااسکریپت پیاده کنیم و در نهایت به خروجی زیر خواهیم رسید:


raycasting in javascript
همچنین میتونید به صورت زنده در این لینک با پروژه کار کنید

توضیحاتی درباره الگوریتم پیاده سازی

هر یک از Ray ها میتونن در یک جهات تا بی نهایت ادامه داشته باشند. اگر یکی از این خط های ما به دیواری بخورد کرد ما باید الگوریتمی داشته باشیم که بتونیم برخورد Ray به دیوار رو تشخیص بدیم

و اگر این برخورد وجود داشت از نقطه ی شروع ray تا نقطه برخورد یک خط رسم میکنیم.

همونطور که در طرح نهایی مشاهده میکنید هر خط میتونه سایه هم داشته باشه برای رسم این سایه ها میتونیم بعد از نقطه ی برخورد خط به دیوار، خط سایه رو در جهت ray تا بینهایت ادامه بدیم تا در نهایت به اون افکت گیف بالا برسیم

دقت کنید اینجا هدف فقط توضیح نحوه ی پیاده سازی هست. مثلا کد های مربوط به رسم اشکال در canvas و... توضیح داده نمیشه تا مقاله طولانی نشه


مقدمات پروژه

خب برای شروع ابتدا یه فایل index.html بسازید. برای پیاده سازی Raycasting ما نیاز به یک Canvas داریم

index.html
<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)


ساخت منبع نور

raycasting light source

منبع نور جایی هست که 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 درجه دور منبع نورم بچینیم

creating ray inside light class

برای اینکار، ابتدا کلاس 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 ورود هست:

  • pos: نقطه مختصات این ray
  • direction: جهت حرکت این ray

و فانکشن 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 منبع سورس اون رو رندر میکنیم

اگر تا اینجا با من پیش اومده باشید باید همچین صحنه رو روی مانیتور خودتون ببینید

generated rays inside Light class

حالا وقت به حرکت در آوردن این منبع نور هست. اگر یادتون باشه در ابتدای آموزش ما یک 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)
}

با هر بار رفرش صفحه ما طرح مختلفی از دیوار ها رو خواهیم داشت

generate random walls

خب حالا به مرحله جالب این پروژه میرسیم، تشخیص اینکه آیا ray با دیوار برخورد کرده یا نه

line line intersection

برای اینکه این بخش رو متوجه بشید حتما پیشنهاد میکنم نگاهی به این صفحه ویکیپدیا بزنید

فرض کنید ما نقاط شروع و پایان هر دو خط رو داریم برای اینکه حساب کنیم آیا این 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 باید با همه ی دیوار ها چک بشه و اگر برخوردی وجود داشت یک خط از منبع نور تا دیوار رسم کنه.

خروجی کد بالا به صورت زیر خواهد بود:

cast rays on wall

مشکلی که اینجا به وجود اومد این هست که ممکنه یک 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()
    }
}

بعد از اعمال تغییرات بالا میبینید که مشکل قبلی حل شد

cast rays on the closest wall

اضافه کردن سایه

برای مرحله آخر از این پروژه میتونیم به دیوار هایی که 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 بعدی با زبان جاوااسکریپت پیاده سازی کنیم

امیدوارم این آموزش براتون مفید بوده باشه

اگر جایی مشکل داشتید میتونید سورس کد این پروژه رو در گیتهاب مشاهده کنید

[سورس کد پروژه]