2.6 ความสุ่มและ Noise

เฟส 2 · คณิตศาสตร์สำหรับเกม · เวลาเรียน: 15–25 h

เลขสุ่มเทียมและการแจกแจง บวก noise แบบ Perlin และ Simplex — เครื่องมือเบื้องหลัง procedural generation, VFX และความหลากหลายที่ดูเป็นธรรมชาติ

ทุกเกมใช้ความสุ่มอยู่ที่ใดที่หนึ่งเสมอ: ศัตรูตัวไหนจะ spawn, hit นี้ดาเมจเท่าไหร่, item อะไรดรอป, ภูเขาลูกนี้มีรูปทรงยังไง, คบไฟกระพริบแบบไหน บทนี้ว่าด้วยเรื่องที่คอมพิวเตอร์ — เครื่องที่ deterministic ล้วน ๆ (input เดิม ให้ output เดิมเสมอ) — ทำให้เกิดสิ่งที่ ดูเหมือน สุ่มได้ยังไง วิธีใช้มันให้ถูกต้อง (มีวิธีที่ดู "ชัดเจน" อยู่หลายแบบที่จริง ๆ แล้วพังแบบเงียบ ๆ) แล้วก้าวไปอีกขั้น คือสร้างความสุ่มที่ดู เป็นธรรมชาติ แทนที่จะดูวุ่นวาย ซึ่งเป็นสิ่งที่ terrain, เมฆ, และกล้องสั่นต้องการจริง ๆ

เหมือนเดิม: โค้ดสั้น ๆ, output จริง, อธิบายง่าย ๆ ตัวเลขทุกตัวที่แสดงด้านล่างคือ output จริงจากโปรแกรม C++ เล็ก ๆ ที่ถูกคอมไพล์และรันจริง — ลองพิมพ์ตามแล้วรันดู คุณจะได้ผลลัพธ์เดียวกัน

1. Pseudo-random numbers: ดูสุ่มเฉย ๆ

คอมพิวเตอร์โยนเหรียญจริงไม่ได้ แต่ pseudo-random number generator (PRNG, "pseudo" แปลว่า "ปลอม" — มันแค่คล้ายของจริงเท่านั้น) สร้างตัวเลขด้วยสูตรคงที่ คุณให้ค่าเริ่มต้นที่เรียกว่า seed แล้วจากนั้นตัวเลขใหม่แต่ละตัวจะถูกคำนวณจาก state ก่อนหน้าด้วยสูตรนั้น ลำดับที่ได้ดูสุ่มสำหรับคนที่มองผ่าน ๆ แต่จริง ๆ มันถูกกำหนด 100% โดย seed seed เดิม ก็ได้ลำดับเดิมเป๊ะ ตลอดไป

นี่คือหนึ่งใน PRNG ที่ง่ายที่สุดที่มีอยู่ เรียกว่า linear congruential generator (LCG): state = state * A + B โดยใช้ A กับ B คงที่ เราใช้ unsigned 32-bit integer ดังนั้นเมื่อการคูณ overflow มันจะ wrap around กลับมา — การ wrap around นี่แหละที่ทำให้ output กระโดดไปมาแบบคาดเดายาก

#include <cstdint>
#include <iostream>

struct Rng { uint32_t state; };

// One step of a linear congruential generator (LCG): a formula that
// turns the current state into a new state. Same formula, same state
// in -> same state out, every single time.
uint32_t nextRaw(Rng& r) {
    r.state = r.state * 1103515245u + 12345u;   // wraps around on overflow (that's fine)
    return r.state;
}

int rollDie(Rng& r) {
    return (nextRaw(r) % 6) + 1;   // 1..6 (we will fix the % trap in section 3)
}

int main() {
    Rng a{42};                     // seed = 42
    for (int i = 0; i < 8; i++) std::cout << rollDie(a) << " ";
    std::cout << "\n";

    Rng b{42};                     // same seed as a
    for (int i = 0; i < 8; i++) std::cout << rollDie(b) << " ";
    std::cout << "\n";

    Rng c{7};                      // different seed
    for (int i = 0; i < 8; i++) std::cout << rollDie(c) << " ";
    std::cout << "\n";
}

Output:

6 5 2 1 4 3 2 3
6 5 2 1 4 3 2 3
3 4 5 4 1 4 1 4

ดูดี ๆ: การทอย a กับ b พิมพ์ตัวเลขแปดตัว เหมือนกันเป๊ะ เพราะทั้งคู่เริ่มจาก seed เดียวกัน (42) และใช้สูตรเดียวกัน การทอย c เริ่มจาก seed 7 และได้ลำดับที่ต่างออกไปโดยสิ้นเชิง ไม่มีอะไรมหัศจรรย์ตรงนี้เลย — state ก็แค่ตัวเลข และ state = state * A + B ก็แค่เลขคณิตธรรมดา ถ้าคุณรู้ seed กับสูตร คุณทายผลการทอยทุกครั้งล่วงหน้าได้ นั่นแหละคือความหมายของ "pseudo"

Tip โค้ดจริงไม่ควรเขียน LCG เองแบบนี้ — มันมีจุดอ่อนที่รู้กันอยู่ (เช่น bit ต่ำ ๆ มี pattern ซ้ำสั้น ๆ) เราสร้างมันขึ้นมาตรงนี้แค่เพื่อให้คุณเห็นกลไกด้วยตาตัวเอง ตั้งแต่หัวข้อ 2 เป็นต้นไปเราจะเปลี่ยนไปใช้ generator จริงของ C++ คือ std::mt19937 ซึ่งเป็น PRNG คุณภาพสูงกว่ามาก (เรียกว่า Mersenne Twister) แต่ทำงานบนหลักการเดียวกันเป๊ะ: ใส่ seed เข้าไป ได้ลำดับ deterministic ออกมา

2. Seed เดิม ลำดับเดิม: ทำไม determinism ถึงสำคัญ

standard library ของ C++ ให้ PRNG ที่ใช้งานจริงมาให้ คือ std::mt19937 พร้อม object ช่วยที่เรียกว่า distribution ซึ่งแปลง output ดิบของมันให้เป็นรูปแบบความสุ่มที่คุณต้องการจริง ๆ (int ในช่วงที่กำหนด, float ในช่วงที่กำหนด และอื่น ๆ — เราจะใช้มันตั้งแต่หัวข้อถัดไป) มันทำงานเหมือน LCG ของเล่นที่เราสร้างเองเป๊ะ: สร้างมันด้วย seed แล้วมันจะให้ลำดับเดิมแบบ deterministic ทุกครั้ง

#include <random>
#include <iostream>

int main() {
    std::mt19937 rngA(1234);                    // real production-quality PRNG, fixed seed
    std::uniform_int_distribution<int> die(1, 6);
    std::cout << "run A: ";
    for (int i = 0; i < 8; i++) std::cout << die(rngA) << " ";
    std::cout << "\n";

    std::mt19937 rngB(1234);                    // same seed
    std::uniform_int_distribution<int> die2(1, 6);
    std::cout << "run B: ";
    for (int i = 0; i < 8; i++) std::cout << die2(rngB) << " ";
    std::cout << "\n";
}

Output:

run A: 4 6 5 5 1 2 2 2
run B: 4 6 5 5 1 2 2 2 

รันโปรแกรมนี้ร้อยครั้ง ทั้งสองบรรทัดพิมพ์ตัวเลขเดิมเป๊ะทุกครั้ง เพราะ seed (1234) ไม่เคยเปลี่ยน ฟังดูเป็นมุขน่าเบื่อ แต่นี่คือหนึ่งใน property ที่มีประโยชน์ที่สุดในโลกของการเขียนเกมเลย

ทำไมมันสำคัญสำหรับการเทสต์

ลองนึกถึง bug report ที่บอกว่า "บางทีบอสดรอปไอเทมสองชิ้นแทนที่จะเป็นชิ้นเดียว" ถ้าโค้ด loot ของคุณดึงจาก generator ที่ seed ด้วยเวลาปัจจุบัน bug นั้นจะเกิดในบางรันแต่ไม่เกิดในบางรัน — คุณ reproduce มันซ้ำ ๆ ไม่ได้แน่นอน ทำให้ debug แทบเป็นไปไม่ได้ ถ้า test harness ของคุณ seed RNG ด้วยเลขคงที่แทน ลำดับเหตุการณ์ "สุ่ม" เดิมเป๊ะจะเกิดขึ้นทุกรัน crash จะกลายเป็นสิ่งที่ reproduce ได้ 100% นี่คือเหตุผลที่โค้ดเบสเกมจริง ๆ จัง ๆ มักให้คุณใส่ seed เองได้เวลาเทสต์ แม้ว่า gameplay จริงจะใช้ seed ที่คาดเดาไม่ได้ก็ตาม

ทำไมมันสำคัญสำหรับเกมออนไลน์

เกมมัลติเพลเยอร์บางเกม (โดยเฉพาะเกมแนว real-time strategy) ใช้เทคนิคที่เรียกว่า lockstep: แทนที่จะส่งผลลัพธ์สุ่มทุกอันผ่านเน็ตเวิร์ก เครื่องของผู้เล่นทุกคนเริ่มจาก seed เดียวกัน แล้ว simulate ลำดับเหตุการณ์ "สุ่ม" เดียวกัน เองในเครื่อง แบบซิงค์กัน ตราบใดที่การ simulate ของทุกคนทำเลขคณิตแบบเดิมเป๊ะในลำดับเดิมเป๊ะ ทุกคนจะได้ผลลัพธ์เดียวกันเป๊ะในที่สุด โดยใช้ network traffic แค่เศษเสี้ยวเดียวเทียบกับการส่งผลทอยลูกเต๋าทุกครั้ง

Common mistake ความ deterministic ของ lockstep เปราะบางในแบบที่คนมักไม่คาดคิด: floating-point arithmetic สามารถให้ผลต่างกันนิดหน่อยระหว่าง CPU, compiler, หรือ optimization setting ที่ต่างกันได้ แม้ source code จะเหมือนกันเป๊ะ แค่ bit เดียวที่พลิกไปตั้งแต่ต้น simulation ที่ยาว ๆ จะลุกลามกลายเป็น desync เต็มรูปแบบ สตูดิโอที่พึ่งพา lockstep มักจำกัดตัวเองให้ใช้ fixed-point (คณิตศาสตร์แบบ integer) สำหรับทุกอย่างที่ต้องซิงค์กัน หรือไม่ก็ควบคุม floating-point environment บนทุกเครื่องอย่างระมัดระวังมาก นี่คือกับดักจริงที่รู้กันในวงการ — ควรจำไว้ตั้งแต่ก่อนที่คุณจะต้องเจอมันจริง ๆ

เวลาที่คุณต้องการความคาดเดาไม่ได้จริง ๆ — gameplay จริง ไม่ใช่การเทสต์ — ให้ seed จากแหล่ง entropy จริง แทนที่จะใช้เลขคงที่ เช่น std::random_device (มันดึงข้อมูลจาก operating system ซึ่งเก็บ noise จากสิ่งต่าง ๆ เช่น hardware timing) หลักการง่าย ๆ คือ: seed คงที่สำหรับอะไรก็ตามที่ต้อง repeatable, seed จาก entropy สำหรับอะไรก็ตามที่ควรทำให้ผู้เล่นเซอร์ไพรส์จริง ๆ

3. Random integer ในช่วงที่กำหนด: กับดัก modulo bias

วิธีที่คนมักคิดถึงเป็นอันดับแรกในการแปลงเลขสุ่มดิบให้เป็น "เลขจาก 0 ถึง N-1" คือ raw % N (operator %, modulo, ให้เศษที่เหลือหลังจากหาร) วิธีนี้ดูถูกต้อง และบ่อยครั้ง ก็ ใกล้เคียงพอในทางปฏิบัติ — แต่มันมี bias เล็ก ๆ แฝงอยู่เมื่อไหร่ก็ตามที่ช่วงของ generator ไม่ใช่ตัวคูณลงตัวของ N และถ้าช่วงเล็ก bias นี้จะเห็นได้ชัด

สมมติว่า generator ดิบของเราให้ค่าได้แค่สิบค่าคือ 0 ถึง 9 (จงใจใช้ช่วงเล็กเพื่อให้เราเช็คเลขด้วยมือได้) เราอยากสุ่มเลือก 0, 1, หรือ 2 อย่างแฟร์ ๆ สิบไม่หารสามลงตัว (10 = 3*3 + 1) จึงมีค่าหนึ่งเหลืออยู่:

#include <iostream>

int main() {
    // Pretend our raw generator only ever outputs 0..9 (easy to check by hand).
    int counts[3] = {0, 0, 0};
    int state = 0;
    for (int i = 0; i < 10000; i++) {
        state = (11 * state + 3) % 10;   // cycles through 0..9
        int bucket = state % 3;          // naive "pick 0, 1, or 2" -- BIASED
        counts[bucket]++;
    }
    std::cout << "naive %3   -> 0:" << counts[0]
               << " 1:" << counts[1] << " 2:" << counts[2] << "\n";

    // Fix: reject the leftover value (9) so only 0..8 (9 values, divides evenly by 3) is used.
    int counts2[3] = {0, 0, 0};
    state = 0;
    int accepted = 0;
    for (int i = 0; i < 10000 && accepted < 9000; i++) {
        state = (11 * state + 3) % 10;
        if (state == 9) continue;        // reject, try again next loop
        counts2[state % 3]++;
        accepted++;
    }
    std::cout << "rejection  -> 0:" << counts2[0]
               << " 1:" << counts2[1] << " 2:" << counts2[2] << "\n";
}

Output:

naive %3   -> 0:4000 1:3000 2:3000
rejection  -> 0:3000 1:3000 2:3000

ในเวอร์ชันไร้เดียงสา bucket 0 มา 4000 ครั้งจาก 10000 — คือ 40% ไม่ใช่ 33.3% ที่แฟร์ — เพราะค่า 0, 3, 6, และ 9 ทั้งหมดแมปไปที่ bucket 0 (สี่ค่า) ในขณะที่ bucket 1 กับ 2 ได้แค่สามค่าต่อตัว เอียงนิดเดียว แต่เป็น bias จริง และยิ่งแย่ลงถ้าช่วงยิ่งห่างจากตัวคูณลงตัว

วิธีแก้ที่ใช้ข้างบนเรียกว่า rejection sampling: โยนค่าดิบที่เหลืออันเดียว (9) ที่จะทำให้ไม่เท่าเทียมทิ้งไป แล้วทอยใหม่ เหลือแค่ 0-8 (เก้าค่า หารสามลงตัวเป็นสามกลุ่มกลุ่มละสาม) ผลลัพธ์จะแฟร์เป๊ะ: 3000/3000/3000

Tip คุณไม่ต้องเขียน rejection sampling เองหรอก std::uniform_int_distribution<int> (ที่ใช้ไปแล้วในหัวข้อ 2 สำหรับลูกเต๋า) ทำแบบนี้ให้เองข้างในอยู่แล้ว ถูกต้องด้วย สำหรับช่วงไหนก็ได้ ใช้มันแทน % ดิบ ๆ เสมอเมื่อช่วงไม่ใช่เลขยกกำลังสองที่พอดีกับความกว้าง output ของ generator กฎง่าย ๆ คือ: อย่าเขียน rng() % n สำหรับ game logic เด็ดขาด — ผ่าน distribution จริงเสมอ

4. Random float ในช่วงที่กำหนด

หลายอย่างในเกมไม่ใช่เลขจำนวนเต็ม — เวลาหน่วงก่อน spawn, มุมกระจาย, ตัวคูณ scale std::uniform_real_distribution<float> ให้ float สุ่มในช่วงที่กำหนด และโดย default ช่วงนั้นเป็น half-open: รวมขอบล่างแต่ไม่รวมขอบบน เขียนเป็น [lo, hi)

#include <random>
#include <iostream>

int main() {
    std::mt19937 rng(2024);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);   // range [0, 1)

    std::cout << "5 random floats in [0,1): ";
    for (int i = 0; i < 5; i++) std::cout << unit(rng) << " ";
    std::cout << "\n";

    std::mt19937 rng2(2024);                                  // same seed again
    float lo = 10.0f, hi = 20.0f;
    std::cout << "5 random floats in [10,20): ";
    for (int i = 0; i < 5; i++) {
        float f = lo + unit(rng2) * (hi - lo);                // remap [0,1) into [lo,hi)
        std::cout << f << " ";
    }
    std::cout << "\n";
}

Output:

5 random floats in [0,1): 0.588015 0.757153 0.699109 0.738747 0.188152
5 random floats in [10,20): 15.8801 17.5715 16.9911 17.3875 11.8815 

สังเกตว่ารันที่สองใช้ seed เดิมเป๊ะ และใช้ distribution object [0,1) ตัวเดิมเป๊ะ — ดังนั้นเศษส่วนดิบ (0.588, 0.757...) จึงเหมือนกันเป๊ะ และบรรทัดที่สองก็แค่บรรทัดแรกที่ถูก remap ด้วย lo + t * (hi - lo) สูตร remap นี้ — เศษส่วน t จาก 0 ถึง 1 ที่ถูก scale แล้วเลื่อนเข้าไปในช่วงไหนก็ได้ที่คุณต้องการ — จะกลับมาอีกเรื่อย ๆ ตลอดทั้งบทนี้ รวมถึงในหัวข้อเรื่อง noise ด้วย คุณจะข้ามการ remap เองแล้วสร้าง std::uniform_real_distribution<float>(lo, hi) ตรง ๆ เลยก็ได้ ทั้งสองวิธีนี้พบได้ทั่วไปในโค้ดจริง

5. Weighted random choice: loot table กับ gacha rate

การสุ่มเลือกแบบ uniform ธรรมดาให้ทุกตัวเลือกโอกาสเท่ากัน แต่ระบบ loot ส่วนใหญ่ไม่ต้องการแบบนั้น — item Legendary ควรจะแรร์ item Common ควรจะเจอบ่อย Loot table (ลิสต์ของไอเทมที่ดรอปได้ แต่ละตัวมี weight คุมความน่าจะเป็นของมัน) แก้ปัญหานี้ด้วย cumulative weight: เอา weight มาเรียงต่อกันบนเส้นจำนวน ทอยเลขสุ่มหนึ่งตัวข้ามเส้นทั้งเส้น แล้วดูว่ามันตกอยู่ในช่วงไหน

loot table weights (should add up to 100 to read as percent): Common 60 Rare 25 Epic 12 Legendary 3 -------------- total 100 turn the weights into CUMULATIVE ranges on a 0..100 number line: 0 60 85 97 100 |-----------|---------|----|--| | Common | Rare |Epic|Lg| |-----------|---------|----|--| roll = randomFloat(0, 100) one roll picks one item roll = 42.0 -> falls in [0, 60) -> Common roll = 91.5 -> falls in [85, 97) -> Epic roll = 99.1 -> falls in [97, 100) -> Legendary
#include <random>
#include <iostream>
#include <string>
#include <vector>

struct Item { std::string name; float weight; };

std::string pickWeighted(std::vector<Item>& table, float totalWeight, std::mt19937& rng) {
    std::uniform_real_distribution<float> unit(0.0f, totalWeight);
    float roll = unit(rng);            // one roll in [0, totalWeight)
    float cursor = 0.0f;
    for (auto& it : table) {
        cursor += it.weight;
        if (roll < cursor) return it.name;   // roll landed in this item's slice
    }
    return table.back().name;          // safety net for float rounding
}

int main() {
    std::vector<Item> table = {
        {"Common",    60.0f},
        {"Rare",      25.0f},
        {"Epic",      12.0f},
        {"Legendary",  3.0f},
    };
    float total = 0.0f;
    for (auto& it : table) total += it.weight;

    std::mt19937 rng(99);
    int counts[4] = {0, 0, 0, 0};
    const int N = 10000;
    for (int i = 0; i < N; i++) {
        std::string got = pickWeighted(table, total, rng);
        for (size_t k = 0; k < table.size(); k++)
            if (table[k].name == got) counts[k]++;
    }
    for (size_t k = 0; k < table.size(); k++)
        std::cout << table[k].name << ": " << counts[k]
                   << " (" << (100.0 * counts[k] / N) << "%)\n";
}

Output:

Common: 5965 (59.65%)
Rare: 2586 (25.86%)
Epic: 1168 (11.68%)
Legendary: 281 (2.81%)

หมื่นครั้งของการสุ่ม เปอร์เซ็นต์ที่วัดได้ (59.65%, 25.86%, 11.68%, 2.81%) ใกล้เคียงกับ weight เป้าหมาย (60, 25, 12, 3) — ไม่เป๊ะเพราะยังเป็นการสุ่มอยู่ แต่ใกล้เคียง และจะยิ่งใกล้ขึ้นถ้าจำลองมากขึ้น เทคนิค cumulative-weight นี้ ที่ทอยเลขสุ่มเดินไปบนเส้นจำนวน คือวิธีที่ระบบ loot แบบ gacha ทำงานข้างในจริง ๆ — เกมสไตล์ HoYoverse หลายเกมใช้ base rate ประมาณ 0.5-0.6% สำหรับ tier ที่แรร์ที่สุดต่อการสุ่มหนึ่งครั้ง

Tip ระบบ gacha จริง ๆ มักเพิ่มกลไก pity เข้ามาด้วย: ถ้าสุ่มไปหลายครั้งแล้วยังไม่ได้ tier แรร์ เกมจะเพิ่ม weight ของ tier นั้น (soft pity) แล้วสุดท้ายก็การันตีให้เลยที่จำนวนครั้งหนึ่ง (hard pity) สังเกตว่านี่ไม่ใช่ระบบแยกต่างหาก — มันคือ weighted table ตัวเดิมนี่แหละ แค่ปรับ weight ตามจำนวนครั้งที่สุ่มไปตั้งแต่ได้ของแรร์ครั้งล่าสุด

6. การ shuffle list: อัลกอริทึม Fisher-Yates

การ shuffle หมายถึงการจัดลิสต์ให้อยู่ในลำดับสุ่มจริง ๆ — ทุกแบบใน n! ("n factorial" จำนวนวิธีเรียง n ตัว) ที่เป็นไปได้ควรมีโอกาสเท่ากัน Fisher-Yates shuffle ทำแบบนี้ได้ถูกต้องและต้นทุนต่ำ ในการวนรอบเดียว: เดินจาก index สุดท้ายลงมาถึง index 1 แล้วในแต่ละก้าว สลับตำแหน่งนั้นกับตำแหน่งสุ่มที่เลือกมาจาก 0 จนถึง index ปัจจุบัน — ไม่แตะ index ที่ถูกจัดเรียบร้อยแล้วอีกเลย

#include <random>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> deck = {1, 2, 3, 4, 5, 6, 7, 8};
    std::mt19937 rng(7);

    // Walk from the LAST index down to 1. At each step, swap that slot
    // with a uniformly random slot from 0..i (inclusive) -- including
    // possibly itself. Never touch an index after it has been fixed.
    for (int i = (int)deck.size() - 1; i > 0; i--) {
        std::uniform_int_distribution<int> pick(0, i);
        int j = pick(rng);
        std::swap(deck[i], deck[j]);
        std::cout << "i=" << i << " j=" << j << "  -> ";
        for (int x : deck) std::cout << x << " ";
        std::cout << "\n";
    }
}

Output:

i=7 j=7  -> 1 2 3 4 5 6 7 8
i=6 j=4  -> 1 2 3 4 7 6 5 8
i=5 j=1  -> 1 6 3 4 7 2 5 8
i=4 j=3  -> 1 6 3 7 4 2 5 8
i=3 j=3  -> 1 6 3 7 4 2 5 8
i=2 j=0  -> 3 6 1 7 4 2 5 8
i=1 j=1  -> 3 6 1 7 4 2 5 8 

ไล่ดูทีละก้าว: ก้าวแรก i=7 เลขสุ่ม j เป็นอะไรก็ได้ตั้งแต่ 0 ถึง 7 (array ทั้งเส้นยัง "มีชีวิต" อยู่หมด) พอ index 7 ถูกสลับแล้ว มันจะไม่ถูกแตะอีกเลย — ก้าวถัดไปเลือกได้แค่ 0 ถึง 6 ช่วงที่หดลงเรื่อย ๆ นี่แหละคือเคล็ดลับทั้งหมด: มันการันตีว่าทุกแบบใน 8! = 40320 แบบที่เป็นไปได้มีโอกาสเท่ากัน ไม่มี bias เหลือค้าง

Common mistake บั๊กที่พบบ่อยมากดูคล้าย Fisher-Yates เกือบเป๊ะแต่ไม่ใช่: สลับทุก index i กับ index สุ่มที่เลือกจาก ทั้ง array ทุกครั้ง (0..n-1 ไม่ใช่ 0..i ที่หดลง) แทนที่จะหดช่วงลงเรื่อย ๆ มันคอมไพล์ผ่าน รันได้ ดูเหมือน shuffle แล้วด้วยซ้ำ — แต่มันมี bias ที่วัดได้จริง รันทั้งสองเวอร์ชันอย่างละ 60,000 ครั้งบน list 4 ตัว (24 แบบที่เป็นไปได้ ดังนั้น shuffle ที่แฟร์ควรได้ประมาณ 2500 ต่อแบบ) ผลคือ: เวอร์ชันบั๊กแบบ full-range มีช่วงตั้งแต่ต่ำสุด 1880 ถึงสูงสุด 3401 สำหรับแบบต่าง ๆ ในขณะที่ Fisher-Yates ที่ถูกต้องอยู่ในช่วงแคบ ๆ ระหว่าง 2380 ถึง 2616 บางแบบเกิดขึ้นบ่อยกว่าแบบอื่นจริง ๆ ในเวอร์ชันบั๊ก — มากพอที่จะมีผลถ้า "shuffle" นั้นควบคุมอะไรบางอย่างที่ผู้เล่นวัดได้ เช่นสำรับไพ่ในเกมการ์ด

7. จุดสุ่มภายในวงกลมและสี่เหลี่ยม

การกระจายอะไรบางอย่างทั่วพื้นที่ — spawn particle, วาง foliage, เลือกจุดตกของดาเมจเป็นพื้นที่ — ต้องการจุดที่ถูกเลือกแบบ uniform ทั่วรูปทรงนั้น หมายความว่าทุกพื้นที่ย่อยที่เท่ากันมีโอกาสได้จุดเท่ากัน

สี่เหลี่ยม: ง่ายมาก

สี่เหลี่ยมเป็นกรณีง่าย: ความกว้างกับความสูงเป็นอิสระต่อกัน แค่สุ่ม float อิสระสองตัว ตัวละแกน

#include <random>
#include <iostream>

int main() {
    std::mt19937 rng(3);
    std::uniform_real_distribution<float> ux(0.0f, 200.0f);   // rectangle width  200
    std::uniform_real_distribution<float> uy(0.0f, 100.0f);   // rectangle height 100

    std::cout << "5 random points inside a 200x100 rectangle:\n";
    for (int i = 0; i < 5; i++) {
        float x = ux(rng);
        float y = uy(rng);
        std::cout << "  (" << x << ", " << y << ")\n";
    }
}

Output:

5 random points inside a 200x100 rectangle:
  (110.16, 7.07249)
  (141.63, 83.9949)
  (58.1809, 12.1329)
  (102.166, 56.9311)
  (178.589, 43.7062)

วงกลม: rejection sampling

วงกลมไม่ใช่สองแกนอิสระ วิธีเดิมจึงใช้ตรง ๆ ไม่ได้ วิธีที่ถูกต้องและง่ายที่สุดคือใช้ rejection sampling อีกครั้งเหมือนหัวข้อ 3: สร้างจุดในสี่เหลี่ยมที่ล้อมรอบวงกลมอยู่ แล้วเก็บไว้ก็ต่อเมื่อมันตกอยู่ในวงกลมจริง ๆ — ถ้าไม่ใช่ก็โยนทิ้งแล้วลองใหม่

square from (-1,-1) to (1,1), circle of radius 1 inscribed: +-------------------+ | x x x | x = rejected (outside the circle) | .-------. | | x . o . | o = accepted (inside the circle) | / o o \ | | | o o o | x| | \ o o / | | x '. o .' | | '-------' x | | x x x | +-------------------+ keep the point only if x*x + y*y <= 1 about 78.5% of points in the square land inside the circle (pi / 4)
#include <random>
#include <iostream>

int main() {
    std::mt19937 rng(3);
    std::uniform_real_distribution<float> unit(-1.0f, 1.0f);   // covers the surrounding square

    std::cout << "rejection sampling, points inside a radius-1 circle:\n";
    int found = 0;
    while (found < 5) {
        float x = unit(rng);
        float y = unit(rng);
        if (x * x + y * y <= 1.0f) {       // inside the circle? keep it.
            std::cout << "  (" << x << ", " << y << ")\n";
            found++;
        }
        // else: outside the circle -- throw it away and roll again.
    }
}

Output:

rejection sampling, points inside a radius-1 circle:
  (0.101596, -0.85855)
  (0.416296, 0.679898)
  (-0.418191, -0.757343)
  (0.0216552, 0.138623)
  (0.785894, -0.125876)

ทุกจุดที่พิมพ์ออกมาตอบสนอง x*x + y*y <= 1 — ลองเช็คจุดที่สาม: (-0.418)^2 + (-0.757)^2 = 0.175 + 0.573 = 0.748 ต่ำกว่า 1 สบาย ๆ โดยเฉลี่ยประมาณ 78.5% ของจุดดิบในสี่เหลี่ยมจะถูกเก็บไว้ (พื้นที่วงกลมคือ pi คูณรัศมียกกำลังสอง พื้นที่สี่เหลี่ยมคือ 4 คูณรัศมียกกำลังสอง และ pi/4 ≈ 0.785) ดังนั้นวิธีนี้เสียการทอยไปบ้าง แต่ยังคงแฟร์เป๊ะ และสัดส่วนที่โยนทิ้งก็น้อยพอที่จะไม่มีผลกับโค้ด gameplay

Common mistake ทางลัดที่ดูน่าใช้คือ polar coordinates: สุ่มมุมกับสุ่มรัศมีตรง ๆ r = maxRadius * randomFloat(0,1) แล้วแปลงเป็น x/y วิธีนี้ทำให้จุดกระจุกตัวเข้าหาศูนย์กลางมากเกินไป เพราะวงแหวนใกล้ศูนย์กลางมีพื้นที่น้อยกว่าวงแหวนใกล้ขอบมาก แต่ r ที่สุ่มแบบ uniform กลับไปเยือนทั้งสองวงแหวนบ่อยเท่ากัน วัดตรง ๆ: ครึ่งรัศมีด้านในของวงกลมมีแค่ 25% ของพื้นที่วงกลม ดังนั้น sample ที่แฟร์ควรมีจุดอยู่ตรงนั้นประมาณ 25% วิธีไร้เดียงสา r = maxRadius * random() ทำให้มี 49.68% ของ 20,000 sample point อยู่ในครึ่งด้านในนั้น — เกือบสองเท่าของที่ควรจะเป็น วิธีแก้คือใส่ square root เข้าไปตัวเดียว: r = maxRadius * sqrt(randomFloat(0,1)) ซึ่งให้น้ำหนักรัศมีที่มากกว่าบ่อยขึ้นอย่างถูกต้อง วัดได้ 24.81% — ตรงตามที่ควรจะเป็นพอดี

8. Noise: white noise กับ smooth noise

Noise ในบริบทนี้หมายถึงฟังก์ชันที่ให้ค่าที่แปรผันไปตามพื้นที่หรือเวลา — ไม่ใช่เหตุการณ์สุ่มครั้งเดียวแบบทอยลูกเต๋า แต่เป็นสนามของค่าที่แปรผันทั้งสนามที่คุณสุ่ม sample ตรงไหนก็ได้ ความสูงของ terrain, ความสว่างของเมฆ, และ offset การสั่นของกล้อง ล้วนเป็นตัวอย่างของสิ่งที่คุณต้องการเป็น noise มากกว่าเป็นการสุ่มเลือกครั้งเดียว

มีสองแบบที่ต่างกันมาก White noise ให้ค่าสุ่มอิสระใหม่เอี่ยมทุกจุด — ไม่มีความสัมพันธ์ระหว่างเพื่อนบ้านเลย มันถูกเรียกว่า "white" เพราะเปรียบเทียบกับแสงขาวที่มีทุกความถี่เท่า ๆ กัน ทั้งภาพและเสียงมันดูเหมือน static เหมือนหิมะบนทีวีเก่าที่ยังไม่จูนสัญญาณ Smooth noise (คำทั่วไปคือ gradient noise และแบบที่มีชื่อเสียงที่สุดคือ Perlin noise) ถูกสร้างมาให้จุดที่อยู่ใกล้กันมีค่าใกล้เคียงสัมพันธ์กัน ทำให้ทั้งหมดไหลลื่นแทนที่จะกระโดด

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

int main() {
    const int W = 40;

    // White noise: a fresh, independent random value at every x.
    std::mt19937 rngW(11);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::cout << "white  : ";
    for (int x = 0; x < W; x++) {
        int h = (int)(unit(rngW) * 9.0f);
        std::cout << h;
    }
    std::cout << "\n";

    // Smooth noise: random values on a coarse grid, blended between.
    const int spacing = 8;                       // one grid point every 8 units
    const int gridN = W / spacing + 2;
    std::mt19937 rngG(11);
    std::vector<float> grid(gridN);
    for (auto& g : grid) g = unit(rngG);

    std::cout << "smooth : ";
    for (int x = 0; x < W; x++) {
        int   cell = x / spacing;
        float t    = (x % spacing) / (float)spacing;
        float a = grid[cell];
        float b = grid[cell + 1];
        float v = a + smoothstep(t) * (b - a);    // blend a -> b using an eased t
        int h = (int)(v * 9.0f);
        std::cout << h;
    }
    std::cout << "\n";
}

Output (แต่ละหลักคือความสูงจาก 0 ถึง 9 หนึ่งตัวต่อตำแหน่ง x):

white  : 1005416830420847807866048775165608182710
smooth : 1111100000000000001234555555544444332221

อ่านแถว white: 1, 0, 0, 5, 4, 1, 6, 8... เพื่อนบ้านแต่ละตัวไม่เกี่ยวข้องกับตัวก่อนหน้าเลย กระโดดไปมาแบบสุ่มสุด ๆ อ่านแถว smooth: 1, 1, 1, 1, 1, 0, 0, 0... มันไหลจาก plateau หนึ่งไปอีก plateau หนึ่ง ไม่เคยกระโดดเกินสองสามหลักระหว่างเพื่อนบ้าน ไอเดียเดียวกัน วาดเป็นแท่ง — ความต่างเห็นชัดทันทีที่มอง:

WHITE NOISE (jagged, no relation between neighbors): ## # # # ## ## # # # ## ### # # # # # ## ###### # # # ## # ### ###### # # # ## # ### ###### # # #### # ### ###### ## ###### # ############## ###### ## ######################## ------------------------ SMOOTH NOISE (gradient/value noise, neighbors are close in value): ####### # ######### # ########## ## ############ ### ############### ### ################ #### ################# ##### ######################## ######################## ------------------------

White noise คือสิ่งที่คุณต้องการเป๊ะสำหรับการโยนเหรียญหรือสุ่ม loot — ความเป็นอิสระโดยสมบูรณ์คือประเด็นสำคัญ แต่ถ้าคุณใช้ white noise สำหรับความสูงของ terrain ทุก tile ข้าง ๆ กันจะกระโดดไปความสูงที่ต่างกันสุดขั้ว ได้ขยะแหลม ๆ ที่ใช้งานไม่ได้แทนที่จะเป็นภูเขา Smooth noise คือสิ่งที่ terrain, เมฆ, และการเคลื่อนไหวที่เป็นธรรมชาติต้องการจริง ๆ — สองหัวข้อถัดไปจะสร้างมันและใช้มัน

9. สร้าง smooth noise: interpolate grid ของค่าสุ่ม

แถว smooth ข้างบนไม่ได้มายังไงเวทมนตร์ — มันตามสูตรง่าย ๆ ที่เรียกว่า value noise หนึ่งในสมาชิกที่ง่ายที่สุดของตระกูล gradient-noise:

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

float valueNoise1D(const std::vector<float>& grid, int spacing, int x) {
    int   cell = x / spacing;               // which two grid points x falls between
    float t    = (x % spacing) / (float)spacing;   // 0..1, how far between them
    float a = grid[cell];
    float b = grid[cell + 1];
    return a + smoothstep(t) * (b - a);      // eased blend, not a straight line
}

int main() {
    std::mt19937 rng(11);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::vector<float> grid(7);
    for (auto& g : grid) g = unit(rng);       // one fixed random value per grid point

    for (size_t i = 0; i < grid.size(); i++)
        std::cout << "grid[" << i << "] = " << grid[i] << "\n";

    float v = valueNoise1D(grid, 8, 4);       // x=4 is halfway between grid[0] and grid[1]
    std::cout << "noise at x=4 (t=0.5): " << v << "\n";
}

Output:

grid[0] = 0.1803
grid[1] = 0.0683
grid[2] = 0.0195
grid[3] = 0.6647
grid[4] = 0.4632
grid[5] = 0.1941
grid[6] = 0.7249
noise at x=4 (t=0.5): 0.1243

นี่คือค่า grid เป๊ะ ๆ ที่สร้างแถว "smooth" ในหัวข้อ 8 ลองไล่เลขด้วยมือสำหรับ x=4:

one random value sits at every grid point (spacing = 8): x: 0 8 16 24 | | | | grid: 0.18 0.07 0.02 0.66 between two grid points, blend with an EASED t, not a straight line: smoothstep(t) = t*t*(3 - 2*t) t=0.0 -> 0.00 (flat start, matches the left grid point's slope) t=0.25-> 0.156 t=0.5 -> 0.50 (still the midpoint, same as plain linear here) t=0.75-> 0.844 t=1.0 -> 1.00 (flat finish, matches the right grid point's slope) worked example at x=4, cell 0, t=4/8=0.5: v = grid[0] + smoothstep(0.5) * (grid[1]-grid[0]) v = 0.1803 + 0.5 * (0.0683-0.1803) v = 0.1803 + 0.5 * (-0.1120) v = 0.1243

0.1243 * 9 = 1.12 ซึ่งตัดทอนเหลือ 1 — ตรงกับหลักที่ปรากฏที่ตำแหน่ง 4 ในแถว smooth ก่อนหน้าเป๊ะ ทุกค่าในแถวทั้งแถวไม่ใช่อะไรเลยนอกจากการ blend แบบเดียวกันนี้ คำนวณซ้ำ ๆ ที่ x ต่างกัน

Value noise เทียบกับ Perlin noise แท้ ๆ

สิ่งที่เราเพิ่งสร้างคือ value noise: ค่าสุ่มวางอยู่ที่จุด grid Perlin noise คลาสสิก (คิดค้นโดย Ken Perlin สำหรับหนัง Tron และภายหลังได้รับรางวัล technical Academy Award) เป็นญาติใกล้ชิดที่เก็บทิศทางสุ่ม (vector gradient) ไว้ที่จุด grid แทนที่จะเป็นค่าธรรมดา แล้ว blend ตามว่าจุด sample แต่ละจุดอยู่ไกลแค่ไหนตามทิศทางนั้น มันดูลื่นกว่านิดหน่อยและหลีกเลี่ยงลุคแบบ "โป่งตรงจุด grid พอดี" ที่ value noise ธรรมดามีได้ — แต่ไอเดียหลักเหมือนกันเป๊ะ: grid สุ่ม, blend ระหว่างเพื่อนบ้าน, ease การ blend พอ value noise เข้าใจแล้ว Perlin noise ก็แค่การปรับปรุงให้ดีขึ้น ไม่ใช่คอนเซปต์ใหม่ ในโปรเจกต์จริงคุณมักจะไม่เขียนเองทั้งสองแบบหรอก — Unity มี Mathf.PerlinNoise ให้ในตัว, Unreal มี noise node ใน material กับ Blueprint system, และมี library อย่าง FastNoiseLite สำหรับที่เหลือ — แต่การรู้กลไกทำให้คุณเข้าใจจริง ๆ ว่า parameter ของมันทำอะไร

10. ใช้ noise: heightmap, texture, และการเคลื่อนไหวที่เป็นธรรมชาติ

Heightmap: terrain ที่ดูเป็นธรรมชาติ

Heightmap คือ grid ของตัวเลขที่แต่ละตัวคือความสูงของ terrain ตรงจุดนั้น สุ่ม sample 2D smooth noise หนึ่งครั้งต่อ tile แล้วใช้ผลลัพธ์เป็นความสูงของ tile นั้น คุณจะได้เนินเขาที่ต่อเนื่องกันแทนที่จะเป็นความยุ่งเหยิงแหลม ๆ — เพราะเหมือนในหัวข้อ 8 กับ 9 เป๊ะ tile ข้างเคียงได้ค่าที่สัมพันธ์กัน

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }
float lerp(float a, float b, float t) { return a + t * (b - a); }

struct Grid2D {
    int n;
    std::vector<float> v;
    float at(int x, int y) const { return v[(y % n) * n + (x % n)]; }
};

float noise2D(const Grid2D& g, int spacing, int x, int y) {
    int cx = x / spacing, cy = y / spacing;
    float tx = (x % spacing) / (float)spacing;
    float ty = (y % spacing) / (float)spacing;
    float v00 = g.at(cx, cy),     v10 = g.at(cx + 1, cy);
    float v01 = g.at(cx, cy + 1), v11 = g.at(cx + 1, cy + 1);
    float top = lerp(v00, v10, smoothstep(tx));    // blend along x on the top edge
    float bot = lerp(v01, v11, smoothstep(tx));    // blend along x on the bottom edge
    return lerp(top, bot, smoothstep(ty));         // blend the two results along y
}

int main() {
    std::mt19937 rng(5);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    Grid2D grid{6, std::vector<float>(36)};
    for (auto& x : grid.v) x = unit(rng);

    const char* bands = " .-~^#";   // low height to high height
    const int W = 24, H = 12, spacing = 4;

    for (int y = 0; y < H; y++) {
        for (int x = 0; x < W; x++) {
            float h = noise2D(grid, spacing, x, y);
            int band = (int)(h * 5.0f);
            if (band > 5) band = 5;
            std::cout << bands[band];
        }
        std::cout << "\n";
    }
}

Output (ช่องว่างและ . คือพื้นต่ำ, ^ กับ # คือยอดเขา):

.     -~^^^^^~-.........
.... .-~^~~~~~-.........
-------~~~--------...---
^^^^^~~---.....-----.-~~
^^^^^^~---.   .-~---.-~^
^^^^^^~---.. ..-------~^
^^~~~~----.....----~~~~^
~~~-----------...-~^^^^^
~~--..--------...-~^^^^^
~~-------------..-~^^^^~
-------...--------~^^^~-
..-~~~-. ..-~~~~~~~^^~-.

นั่นคือเวอร์ชัน 2D ของเทคนิคเดียวกับหัวข้อ 9 เป๊ะ แค่ blend ไปตามสองแกนแทนที่จะเป็นแกนเดียว (noise2D blend ตามแกน x สองครั้ง แล้ว blend ผลลัพธ์ทั้งสองนั้นตามแกน y) คุณจะเห็นเทือกเขาที่ต่อเนื่องกันหนึ่งลูกทางด้านขวาของแมป ไม่ใช่หนามแหลม ๆ กระจัดกระจาย — ความต่อเนื่องนั้นแหละคือประเด็นทั้งหมดของการใช้ smooth noise แทนเลขสุ่มต่อ tile

Texture

2D noise ตัวเดิมเป๊ะ แทนที่จะขับความสูง สามารถขับความสว่างหรือสีต่อพิกเซลได้ — ป้อน output 0..1 เข้าไปเป็นค่า grayscale คุณจะได้ texture เมฆหรือหินอ่อนที่ใช้ได้; ซ้อนสีสองสามสีตาม threshold ของ noise คุณจะได้ pattern แบบลายพราง มันคือฟังก์ชันเดียวกัน แค่แสดงผลต่างกัน

การเคลื่อนไหวที่เป็นธรรมชาติ

Noise ไม่จำเป็นต้อง sample ข้ามพื้นที่เสมอไป — การ sample ข้ามเวลาแทนจะให้ค่าที่เคว้งคว้างอย่างลื่นไหลจากเฟรมสู่เฟรม ซึ่งเป็นสิ่งที่กล้องสั่น, ใบไม้ที่ถูกลมพัด, หรือคบไฟที่กระพริบต้องการเป๊ะ การสุ่ม offset ใหม่ทุกเฟรม (white noise ในมิติเวลา) จะดูเหมือนสั่นกระตุกหรือ static การ sample smooth noise ขณะเวลาเดินไปข้างหน้าจะดูเป็นธรรมชาติ เพราะค่าของแต่ละเฟรมอยู่ใกล้กับเฟรมก่อนหน้า

#include <random>
#include <iostream>
#include <iomanip>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

float noise1D(const std::vector<float>& grid, int spacing, float x) {
    int   cell = (int)x / spacing;
    float t    = ((int)x % spacing) / (float)spacing;
    float a = grid[cell % grid.size()];
    float b = grid[(cell + 1) % grid.size()];
    return a + smoothstep(t) * (b - a);
}

int main() {
    std::mt19937 rng(21);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::vector<float> grid(6);
    for (auto& g : grid) g = unit(rng);

    const int spacing = 5;
    const float amplitude = 4.0f;             // max shake offset, in pixels

    std::cout << std::fixed << std::setprecision(2);
    std::cout << "frame  time  shakeOffset\n";
    for (int frame = 0; frame < 10; frame++) {
        float time = frame * 1.5f;                        // time keeps moving forward
        float n = noise1D(grid, spacing, time);            // smooth 0..1 value
        float offset = (n - 0.5f) * 2.0f * amplitude;      // remap to -amplitude..+amplitude
        std::cout << "  " << frame << "    " << time << "   " << offset << "\n";
    }
}

Output:

frame  time  shakeOffset
  0    0.00   -3.61
  1    1.50   -2.99
  2    3.00   0.28
  3    4.50   1.77
  4    6.00   1.97
  5    7.50   0.96
  6    9.00   -1.26
  7    10.50   -1.69
  8    12.00   -0.59
  9    13.50   0.34

ดูคอลัมน์ offset: -3.61, -2.99, 0.28, 1.77, 1.97, 0.96... — มันไล่ขึ้น ขึ้นสุด แล้วไล่ลงกลับมา ไม่เคยกระโดดแบบสุ่มจากเฟรมสู่เฟรมเลย ป้อนค่าแบบนี้เข้าไปตรง ๆ ใน local position offset ของกล้อง จะได้ความรู้สึกสั่นแบบธรรมชาติ ถ้าป้อน randomFloat(-4,4) ใหม่ทุกเฟรมแทน จะได้ความรู้สึกเหมือนพังหรือสัญญาณกระพริบ — ช่วงตัวเลขเดิม แต่ความรู้สึกต่างกันโดยสิ้นเชิง เพียงเพราะว่าแต่ละค่าสัมพันธ์กับค่าก่อนหน้ายังไง

11. Octave: ซ้อน noise เพื่อเพิ่มรายละเอียด

Smooth noise ชั้นเดียวดูนุ่มและเป็นก้อนกลม ๆ — terrain จริงมีรูปทรงใหญ่แบบเนินเขาต่อเนื่องเหมือนกัน แต่ก็มีก้อนหินและตะปุ่มตะป่ำเล็ก ๆ ทับอยู่บนนั้นด้วย และมีรายละเอียดที่เล็กกว่านั้นอีกทับซ้อนไปอีกที วิธีแก้เรียกว่า octave: บวก noise หลายชั้นเข้าด้วยกันที่ความถี่ (grid ถี่แค่ไหน) และ amplitude (แต่ละชั้นได้รับอนุญาตให้ขยับผลรวมได้มากแค่ไหน) ต่างกัน แต่ละ octave มักจะเพิ่มความถี่เป็นสองเท่าจากตัวก่อนหน้า พร้อมลด amplitude ลงครึ่งหนึ่ง ดังนั้นแต่ละชั้นที่เพิ่มเข้ามาจะให้รายละเอียดที่ละเอียดขึ้นแต่มีอิทธิพลโดยรวมน้อยลง

#include <random>
#include <iostream>
#include <vector>

float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }

float valueNoise1D(const std::vector<float>& grid, int spacing, int x) {
    int   cell = x / spacing;
    float t    = (x % spacing) / (float)spacing;
    float a = grid[cell % grid.size()];
    float b = grid[(cell + 1) % grid.size()];
    return a + smoothstep(t) * (b - a);
}

std::vector<float> makeGrid(int n, int seed) {
    std::mt19937 rng(seed);
    std::uniform_real_distribution<float> unit(0.0f, 1.0f);
    std::vector<float> g(n);
    for (auto& v : g) v = unit(rng);
    return g;
}

int main() {
    const int W = 40;
    auto grid1 = makeGrid(8, 11);    // octave 1: low frequency,  full amplitude
    auto grid2 = makeGrid(8, 22);    // octave 2: 2x frequency,   half amplitude
    auto grid3 = makeGrid(16, 33);   // octave 3: 4x frequency,   quarter amplitude

    std::cout << "octave1 (freq 1, amp 1.0)  : ";
    for (int x = 0; x < W; x++) std::cout << (int)(valueNoise1D(grid1, 8, x) * 9);
    std::cout << "\n";

    std::cout << "octave2 (freq 2, amp 0.5)  : ";
    for (int x = 0; x < W; x++) std::cout << (int)(valueNoise1D(grid2, 4, x) * 9);
    std::cout << "\n";

    std::cout << "octave3 (freq 4, amp 0.25) : ";
    for (int x = 0; x < W; x++) std::cout << (int)(valueNoise1D(grid3, 2, x) * 9);
    std::cout << "\n";

    std::cout << "sum (normalized)           : ";
    for (int x = 0; x < W; x++) {
        float n1 = valueNoise1D(grid1, 8, x) * 1.0f;
        float n2 = valueNoise1D(grid2, 4, x) * 0.5f;
        float n3 = valueNoise1D(grid3, 2, x) * 0.25f;
        float sum = (n1 + n2 + n3) / 1.75f;    // divide by total amplitude, back to 0..1
        std::cout << (int)(sum * 9);
    }
    std::cout << "\n";
}

Output:

octave1 (freq 1, amp 1.0)  : 1111100000000000001234555555544444332221
octave2 (freq 2, amp 0.5)  : 1245665444566654344556677777764212456654
octave3 (freq 4, amp 0.25) : 2465456435852104776413520378887524654564
sum (normalized)           : 1223322222322211222334555666654333444333

ดู octave 1 เดี่ยว ๆ เทียบกับผลรวมสุดท้าย octave 1 มีช่วงแบนราบยาว ๆ — เลขศูนย์ห้าตัวติดกัน เลขห้าหกตัวติดกัน ผลรวมสุดท้ายยังคงรูปทรงขึ้น-ลงคล้ายเดิม (ยังต่ำตรงกลางซ้าย ยังไต่ขึ้นไปทางขวา) แต่ช่วงแบนราบเหล่านั้นหายไป แทนที่ด้วยการกระเพื่อมเล็ก ๆ ตลอดเวลา นั่นแหละคือ octave ที่ทำงานตามที่ตั้งใจเป๊ะ: รูปทรงใหญ่มาจากชั้นความถี่ต่ำสุด และแต่ละชั้นความถี่สูงกว่าจะขัดรายละเอียดทับลงไปโดยไม่เปลี่ยนเงาโดยรวม

octave 1 only (low frequency, sets the big shape): ####### # ######### ## ########### ## ############## ### ############### #### ################# #### ######################## ######################## ------------------------ octave 1 + 2 + 3 combined (adds rolling detail, then fine detail): # #### ###### ## ############### ### ################ #### ######################## ######################## ######################## ------------------------

ใน Unity, Unreal, และ noise library ส่วนใหญ่ คุณจะเห็นสิ่งนี้ถูกเปิดออกมาเป็นพารามิเตอร์ไม่กี่ตัว: octaves (จะเพิ่มกี่ชั้น), lacunarity (ตัวคูณความถี่ต่อ octave มักเป็น 2), และ persistence หรือ gain (ตัวคูณ amplitude ต่อ octave มักเป็น 0.5) ผลรวมแบบซ้อนชั้นนี้มีชื่อทางการด้วย — fractal Brownian motion (fBm) — แต่ "บวก noise สองสามชั้นเข้าด้วยกัน" ก็คือความหมายทั้งหมดของมันจริง ๆ

12. Glossary

13. Exercises

Exercise 1 Generator ดิบให้ค่าได้แค่แปดค่าคือ 0 ถึง 7 แต่ละค่ามีโอกาสเท่ากัน มีคนเลือก bucket ด้วย raw % 3 (bucket 0, 1, และ 2) ให้ระบุว่าค่าดิบไหนแมปไปที่ bucket ไหน นับว่าค่าดิบกี่ค่าตกอยู่ใน bucket แต่ละอัน แล้วบอกว่า bucket ไหนถูกเป็นตัวแทนน้อยเกินไป (under-represented) และมากน้อยแค่ไหนโดยประมาณ (เป็นเปอร์เซ็นต์ของค่าดิบ 8 ค่า)
Show answer

แมปค่าดิบ 0-7 แต่ละตัวผ่าน % 3:

0->0  1->1  2->2  3->0  4->1  5->2  6->0  7->1

Bucket 0 ได้ {0, 3, 6} — 3 ค่า Bucket 1 ได้ {1, 4, 7} — 3 ค่า Bucket 2 ได้ {2, 5} — แค่ 2 ค่า จากค่าดิบ 8 ค่า การแบ่งที่แฟร์ควรได้ประมาณ 2.67 ต่อ bucket (33.3%) Bucket 2 ได้แค่ 2/8 = 25% ดังนั้นมันถูกเป็นตัวแทนน้อยเกินไปประมาณ 8 percentage point ในขณะที่ bucket 0 กับ 1 ถูกเป็นตัวแทนมากเกินไปที่ 3/8 = 37.5% ต่ออัน นี่คือ pattern modulo-bias แบบเดียวกับหัวข้อ 3 เป๊ะ แค่มีค่าดิบ 8 ค่ากับ 3 bucket แทนที่จะเป็น 10 กับ 3

Exercise 2 Loot table มี weight Common=70, Rare=25, Legendary=5 (รวม 100) โดยใช้ cumulative range บนเส้นจำนวน 0-100 การทอยแต่ละครั้งนี้ตกอยู่ที่ไอเทมไหน: roll=12, roll=74, roll=96?
Show answer

สร้าง cumulative range ก่อน: Common ครอบคลุม [0, 70), Rare ครอบคลุม [70, 95) (70+25=95), Legendary ครอบคลุม [95, 100) (95+5=100)

  • roll=12 ตกอยู่ใน [0,70) -> Common
  • roll=74 ตกอยู่ใน [70,95) -> Rare
  • roll=96 ตกอยู่ใน [95,100) -> Legendary

นี่คือลูป cursor += weight; if (roll < cursor) return... จากหัวข้อ 5 เป๊ะ แค่ไล่ด้วยมือแทนที่จะไล่ด้วยโค้ด

Exercise 3 จุด grid สองจุดมี grid[0] = 0.0 และ grid[1] = 1.0 โดยมี spacing 4 คำนวณค่า noise ที่ x=1 (ดังนั้น t = 1/4 = 0.25) สองวิธี: (a) linear interpolation ธรรมดา (v = a + t*(b-a)) และ (b) smoothstep interpolation (v = a + smoothstep(t)*(b-a) โดยที่ smoothstep(t) = t*t*(3-2*t)) แบบไหนจะดูลื่นกว่ากันตรงจุด grid พอดี และทำไม?
Show answer

(a) Linear: v = 0.0 + 0.25 * (1.0 - 0.0) = 0.25

(b) Smoothstep: ก่อนอื่น smoothstep(0.25) = 0.25*0.25*(3 - 2*0.25) = 0.0625 * 2.5 = 0.15625 จากนั้น v = 0.0 + 0.15625 * (1.0 - 0.0) = 0.15625

สองวิธีให้คำตอบต่างกัน (0.25 เทียบกับ 0.15625) เพราะ smoothstep ไม่ใช่เส้นตรง — มันเริ่มแบนกว่าใกล้ t=0 แล้วเร่งขึ้นตรงกลาง ความแบนตรงจุด grid แต่ละจุดพอดีนี่แหละคือเหตุผลที่ smoothstep ดูลื่นกว่า: linear interpolation ธรรมดามีความชันคงที่ไปจนถึงจุด grid แล้วช่วงถัดไปก็เริ่มด้วยความชันคงที่ค่าอื่น ทำให้ทิศทางหักเห็นได้ชัดที่จุด grid ทุกจุด ความชันของ smoothstep เป็นศูนย์ที่ทั้ง t=0 และ t=1 ดังนั้นช่วงหนึ่งจึงชะลอจนหยุดพอดีตรงที่ช่วงถัดไปเริ่มชะลอขึ้น รอยต่อที่จุด grid จึงหายไป

ความสุ่มไม่ใช่ทักษะเดียว แต่เป็นทักษะเล็ก ๆ หลายอันที่ซ้อนกันอยู่: รู้ว่า seed ทำให้ทุกอย่าง reproducible ได้, รู้วิธีแปลงเลขดิบให้เป็นการสุ่มที่แฟร์โดยไม่ติดกับดัก modulo, รู้วิธีถ่วงน้ำหนักตัวเลือกสำหรับ loot, รู้วิธี shuffle และกระจายจุดโดยไม่เอียงไปเข้าข้างผลลัพธ์บางอย่างแบบเงียบ ๆ และรู้ว่า "สุ่ม" กับ "ดูเป็นธรรมชาติ" เป็นเป้าหมายคนละอย่างที่ต้องการเครื่องมือคนละแบบ — white noise สำหรับอย่างแรก, smooth noise ซ้อนชั้นสำหรับอย่างที่สอง แต่ละชิ้นเล็ก ๆ พวกนี้จะโผล่มาให้เห็นตลอดเวลาทันทีที่คุณเริ่มสร้างเกมจริง ๆ

← กลับไปหน้ารวมบท