16.2 Scene Graph และ Transform

เฟส 16 · Engine Programming (C++ ลึก, ไม่บังคับ) · เวลาเรียน: 15–25 h

transform แบบลำดับชั้นที่อัปเดต world matrix แบบ lazy ด้วย dirty flag หนุนด้วย storage แบบ data-oriented เพื่อวนเร็ว

บทที่ 2.1 สร้าง matrix ที่ย้าย หมุน และ scale จุดจุดเดียวได้ แต่เกมจริง ๆ ไม่เคยมีแค่จุดเดียว — มันมีวัตถุเป็นพัน ๆ ชิ้น และส่วนใหญ่ก็ประกอบขึ้นจากชิ้นส่วนเล็ก ๆ ที่ต้องเคลื่อนที่ไปด้วยกัน มือของตัวละครถือดาบ ป้อมปืนของรถถังถือลำกล้อง รถคันหนึ่งมีล้อสี่ล้อ บทนี้พูดถึงโครงสร้างข้อมูลที่ทำให้ "เคลื่อนที่ไปด้วยกัน" เกิดขึ้นได้จริง นั่นคือ scene graph ซึ่งเป็น tree ของ transform ที่ตำแหน่งสุดท้ายของแต่ละวัตถุขึ้นอยู่กับทุกอย่างที่อยู่เหนือมันในสาย

ทุกอย่างในบทนี้ใช้ Vec2, Mat3, mul, และ transformPoint ตัวเดิมเป๊ะจากบทที่ 2.1 — ถ้าชื่อพวกนี้ดูไม่คุ้น ให้กลับไปดูที่บทนั้น

1. ทำไมเกมถึงเก็บวัตถุไว้ใน tree

ลองนึกภาพตัวละครที่ถือดาบอยู่ ดาบควรจะขยับตามเมื่อมือขยับ มือควรขยับตามเมื่อแขนแกว่ง และแขนก็ควรขยับตามเมื่อทั้งตัวละครเดิน คุณอาจจะเก็บตำแหน่งแบบ absolute ของดาบไว้ตัวเดียว แล้วอัปเดตมันเองทุกครั้งที่ส่วนไหนของตัวละครขยับ — แต่นั่นหมายความว่าต้องเขียนตำแหน่ง การหมุน และ scale ของดาบใหม่ทุกเฟรม ด้วยมือ สำหรับวัตถุที่ติดอยู่ทุกชิ้น ตลอดไป แทนที่จะทำแบบนั้น engine ทุกตัวเก็บวัตถุไว้ใน tree (โครงสร้างแบบลำดับชั้นที่แต่ละ item มี parent ได้แค่ตัวเดียว แต่มี children ได้กี่ตัวก็ได้) แล้วปล่อยให้ตำแหน่งเคลื่อนตามไปเองโดยอัตโนมัติ

Root | Body | Arm | Hand | Sword Move or rotate ARM --> HAND moves with it --> SWORD moves with it. Nobody has to touch Hand's or Sword's own numbers -- they inherit the motion just by being PARENTED underneath Arm.

นี่ไม่ใช่เทคนิคพิเศษเฉพาะตัวละครที่ถือดาบเท่านั้น tree แบบเดียวกันนี้โผล่มาทุกที่ในเกม:

แต่ละกล่องใน tree นี้มักถูกเรียกว่า node (คำทั่วไปที่แปลว่า "หนึ่งรายการใน tree" — ใน Unity node คือ Transform ส่วนใน Unreal คือ SceneComponent) node แต่ละตัวเก็บตำแหน่ง การหมุน และ scale ของตัวเอง สัมพัทธ์กับ parent ของมัน ไม่ใช่สัมพัทธ์กับโลก การเลือกออกแบบแบบนี้แค่จุดเดียว — อธิบายตัวเองสัมพัทธ์กับ parent ไม่ใช่สัมพัทธ์กับโลก — คือสิ่งที่ทำให้การขยับวัตถุทั้งกลุ่มมีต้นทุนเท่ากับการขยับตัวเลขแค่ตัวเดียว หัวข้อถัดไปจะทำให้ไอเดีย "สัมพัทธ์กับ parent" นี้ชัดเจนขึ้น

Tip tree ไม่จำเป็นต้องเป็นสายตรง ๆ เสมอไป Root มี children ได้หลายตัว (Body, Camera, AudioListener...) และแต่ละตัวก็มี children ของตัวเองได้อีก "Scene graph" กับ "hierarchy" หมายถึงสิ่งเดียวกัน — คำหลังคือคำที่คุณจะเห็นใน Hierarchy window ของ Unity และ Outliner ของ Unreal

2. Local space กับ world space: สองวิธีอธิบายวัตถุตัวเดียวกัน

node ทุกตัวใน tree มี transform สองแบบที่อธิบายวัตถุตัวเดียวกัน:

artist หรือ designer แทบจะตั้งค่า local transform เสมอ — พวกเขาวาง grip ของดาบให้ห่างจาก origin ของมือ 1 หน่วยไปข้างหน้า ทำครั้งเดียวแล้วไม่แตะมันอีกเลย ต่อให้ตัวละครจะวิ่ง กระโดด และแกว่งดาบไปทั่ว level ก็ตาม ส่วน world transform ต่างหากที่สำคัญจริง ๆ สำหรับการวาดดาบขึ้นจอ สำหรับ physics และสำหรับเช็คว่า "ดาบเล่มนี้โดนศัตรูตัวนั้นไหม" — และมันเปลี่ยนทุกเฟรมที่ตัวละครขยับ ทั้ง ๆ ที่ไม่มีใครแก้ local transform ของดาบเลยสักนิด

LOCAL transform: measured against your PARENT's axes WORLD transform: measured against the one shared axes everyone agrees on world Y ^ | parent's local Y | ^ | | * child, at LOCAL (0, 3) | | / | |/ | parent (somewhere out in the world) | / | / +--/----------------> world X The child's LOCAL position (0,3) never changes just because the parent moves. Its WORLD position changes every time the parent does.

ทั้งสอง transform เก็บด้วยข้อมูลชนิดเดียวกัน: ตำแหน่ง การหมุน และ scale (หรือรวมกันเป็น Mat3/Mat4 ตัวเดียว) ความต่างมีแค่ว่ามันวัด เทียบกับอะไร หัวข้อ 3 จะให้กฎที่ชัดเจนสำหรับเปลี่ยนสาย local transform ให้กลายเป็น world transform เดียว

3. กฎ: world matrix ของ child คือ parent-world คูณ child-local

นี่คือกฎทั้งหมด และมันคือการคูณ matrix ตัวเดิมเป๊ะจากบทที่ 2.1 หัวข้อ 8: childWorld = mul(parentWorld, childLocal) world matrix ของ parent ได้ bake "ทุกอย่างที่อยู่เหนือฉันใน tree" เอาไว้แล้ว การคูณ local matrix ของ child เข้าไปทางขวาก็แค่เพิ่ม step อีกหนึ่ง step บนสายนั้น

Root.world = identity (root has no parent) Body.local --x Root.world--> Body.world Arm.local --x Body.world--> Arm.world Hand.local --x Arm.world ---> Hand.world Sword.local --x Hand.world--> Sword.world Each step only ever needs the PARENT's world matrix, which the previous step already finished computing.

ลองไล่สาย sword/hand/arm/body ทั้งสายจากหัวข้อ 1 ด้วยตัวเลขจริง โดยใช้ Vec2, Mat3, mul, transformPoint, translateMatrix, และ rotationMatrix ตัวเดิมจากบทที่ 2.1:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };
struct Mat3 { float m[3][3]; };

Vec2 transformPoint(const Mat3& M, Vec2 p) {
    float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
    float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
    return { x, y };
}

Mat3 mul(const Mat3& A, const Mat3& B) {   // A * B: apply B first, then A
    Mat3 R{};
    for (int row = 0; row < 3; row++)
        for (int col = 0; col < 3; col++) {
            float sum = 0;
            for (int k = 0; k < 3; k++)
                sum += A.m[row][k] * B.m[k][col];
            R.m[row][col] = sum;
        }
    return R;
}

Mat3 translateMatrix(float tx, float ty) {
    return { { {1, 0, tx}, {0, 1, ty}, {0, 0, 1} } };
}

Mat3 rotationMatrix(float degrees) {
    float radians = degrees * 3.14159265f / 180.0f;
    float c = std::cos(radians);
    float s = std::sin(radians);
    return { { {c, -s, 0}, {s, c, 0}, {0, 0, 1} } };
}

int main() {
    Mat3 rootWorld = { { {1,0,0}, {0,1,0}, {0,0,1} } };            // identity: root has no parent

    Mat3 bodyLocal = translateMatrix(5, 0);                         // body stands at (5, 0)
    Mat3 bodyWorld = mul(rootWorld, bodyLocal);

    Mat3 armLocal = mul(translateMatrix(0, 2), rotationMatrix(90)); // shoulder is 2 up, arm raised 90 degrees
    Mat3 armWorld = mul(bodyWorld, armLocal);

    Mat3 handLocal = translateMatrix(0, 3);                         // forearm is 3 long, no extra rotation
    Mat3 handWorld = mul(armWorld, handLocal);

    Mat3 swordLocal = translateMatrix(1, 0);                        // grip is 1 forward of the hand's origin
    Mat3 swordWorld = mul(handWorld, swordLocal);

    Vec2 origin = {0, 0};
    Vec2 bodyPos  = transformPoint(bodyWorld, origin);
    Vec2 armPos   = transformPoint(armWorld, origin);
    Vec2 handPos  = transformPoint(handWorld, origin);
    Vec2 swordPos = transformPoint(swordWorld, origin);

    std::cout << "body  world = (" << bodyPos.x  << ", " << bodyPos.y  << ")\n";
    std::cout << "arm   world = (" << armPos.x   << ", " << armPos.y   << ")\n";
    std::cout << "hand  world = (" << handPos.x  << ", " << handPos.y  << ")\n";
    std::cout << "sword world = (" << swordPos.x << ", " << swordPos.y << ")\n";
}

Output:

body  world = (5, 0)
arm   world = (5, 2)
hand  world = (2, 2)
sword world = (2, 3)

(เหมือนกับตัวอย่างหมุน 90 องศาในบทที่ 2.1 ตัวเลขจริง ๆ จะมี floating-point noise เล็กน้อยระดับ 1e-7 ซึ่งเล็กเกินกว่าจะโชว์ที่ความละเอียดการพิมพ์นี้ — ให้ถือว่าค่าที่พิมพ์ออกมาคือค่าที่แน่นอน)

ลองไล่ด้วยมือดูว่าทำไมการยกแขนถึงทำให้ทุกอย่างแกว่งตาม: body อยู่ที่ world (5, 0) โดยไม่มีการหมุนเลย local transform ของ arm วางข้อไหล่ไว้สูงกว่า origin ของ body 2 หน่วย แล้วค่อย หมุน 90 องศา ดังนั้นตำแหน่ง world ของ arm ยังคงอยู่เหนือ body ตรง ๆ ที่ (5, 2) เพราะการหมุนรอบ origin ของตัวเองไม่เคยทำให้ origin นั้นขยับเลย แต่ hand อยู่ไกลออกไปอีก 3 หน่วยตามแกน local +Y ของ arm และการหมุน 90 องศาของ arm ได้เปลี่ยนให้แกน "+Y" นั้นชี้ไปทาง world -X แทน — ดังนั้นแทนที่จะไปลงที่ (5, 5) (ตรงขึ้นไปเฉย ๆ ถ้าไม่มีการหมุน) hand กลับไปลงที่ (2, 2) ห่างจากไหล่ไปทาง ซ้าย 3 หน่วย sword รับการหมุนแบบเดียวกันนี้ต่ออีกชั้นหนึ่ง ไปลงที่ (2, 3) ไม่มีใครสั่งให้ hand หรือ sword ขยับไปด้านข้างเลย — มันสืบทอดการหมุนของ arm มาโดยอัตโนมัติ เพราะมันถูก parent ไว้ใต้ arm นั่นเอง

Common mistake เขียน mul(childLocal, parentWorld) แทนที่จะเป็น mul(parentWorld, childLocal) การคูณ matrix ไม่ commutative (บทที่ 2.1 หัวข้อ 8) — สลับลำดับแบบนี้ไม่ error ให้เห็น แต่มันจะเงียบ ๆ ให้ world matrix ที่ผิดออกมา ส่วนใหญ่ child จะถูกแปลงราวกับว่า มันเอง คือ parent ถ้า world position ของ child ดูเหมือนว่า "ควรจะหมุนแต่ไม่หมุน" ให้เช็คลำดับการคูณก่อนเป็นอย่างแรก

4. เดินไต่ tree: คำนวณ world matrix ทุกตัวด้วยโค้ด

หัวข้อ 3 คำนวณ world matrix สี่ตัวด้วยการเขียน mul(...) สี่รอบ ด้วยมือ ตามลำดับที่กำหนดไว้ตายตัว scene จริงมี node เป็นพัน ๆ ตัวและความลึกที่คาดเดาไม่ได้ ดังนั้นแทนที่จะเขียนแต่ละชั้นด้วยมือ ให้สร้าง tree data structure ขึ้นมาจริง ๆ แล้ว walk มัน (ไปเยี่ยมทุก node ตามลำดับที่ประมวลผล parent ก่อน children เสมอ) node หนึ่งตัวต้องการ pointer ไปหา parent, list ของ children, local matrix ของตัวเอง, และที่เก็บ world matrix ที่คำนวณแล้ว:

#include <iostream>
#include <vector>

// Vec2, Mat3, mul(), transformPoint(), translateMatrix(), rotationMatrix()
// are the exact same functions from section 3 (and chapter 2.1) -- omitted
// here to keep the new code visible.

struct Node {
    Mat3 local;
    Mat3 world;
    Node* parent = nullptr;
    std::vector<Node*> children;
};

void addChild(Node& parent, Node& child) {
    child.parent = &parent;
    parent.children.push_back(&child);
}

void updateWorldTransform(Node& node) {
    node.world = node.parent ? mul(node.parent->world, node.local) : node.local;
    for (Node* child : node.children)
        updateWorldTransform(*child);
}

recursion ใน updateWorldTransform คือสิ่งที่ทำให้ "parent ก่อน children" เกิดขึ้นเองโดยอัตโนมัติ: node หนึ่งตัวคำนวณ world matrix ของตัวเองก่อน โดยใช้ field world ของ parent ที่ เสร็จแล้ว แล้วค่อย recurse เข้าไปหา children ทีหลัง — ดังนั้นตอนที่ child ตัวไหนรัน function เดียวกันนี้ world ของ parent มันก็รับประกันว่าถูกต้องแล้ว ทีนี้มาสร้างสาย sword/hand/arm/body เดิมให้เป็น tree จริง ๆ แล้วปล่อยให้ walk คำนวณทุกอย่างให้:

int main() {
    Node root, body, arm, hand, sword;

    root.local  = { { {1,0,0}, {0,1,0}, {0,0,1} } };                 // identity
    body.local  = translateMatrix(5, 0);
    arm.local   = mul(translateMatrix(0, 2), rotationMatrix(90));
    hand.local  = translateMatrix(0, 3);
    sword.local = translateMatrix(1, 0);

    addChild(root, body);
    addChild(body, arm);
    addChild(arm, hand);
    addChild(hand, sword);

    updateWorldTransform(root);   // one call updates the ENTIRE tree

    Vec2 origin = {0, 0};
    for (auto* n : { &body, &arm, &hand, &sword }) {
        Vec2 p = transformPoint(n->world, origin);
        std::cout << "(" << p.x << ", " << p.y << ")\n";
    }
}

Output:

(5, 0)
(5, 2)
(2, 2)
(2, 3)

ได้ตัวเลขสี่ตัวเหมือนกับหัวข้อ 3 เป๊ะ แต่คำนวณด้วยการเรียก updateWorldTransform(root) แค่ครั้งเดียว แทนที่จะเขียน mul ด้วยมือสี่บรรทัด นี่คือรูปแบบที่ transform update ของ engine จริง ๆ ทุกตัวใช้: walk แบบ recursive ครั้งเดียว (หรือแบบ iterative อย่างที่หัวข้อ 8 จะแสดงให้ดู) เริ่มจาก root แล้วประมวลผล parent ให้เสร็จก่อนแตะ children เสมอ

updateWorldTransform(root) root.world = root.local (no parent) -> updateWorldTransform(body) body.world = mul(root.world, body.local) -> updateWorldTransform(arm) arm.world = mul(body.world, arm.local) -> updateWorldTransform(hand) hand.world = mul(arm.world, hand.local) -> updateWorldTransform(sword) sword.world = mul(hand.world, sword.local)

วิธีนี้ใช้ได้ผลและถูกต้อง — แต่ลองดูต้นทุนของมันให้ดี ๆ ทุกครั้งที่เรียก updateWorldTransform(root) มันจะไปเยี่ยม ทุก node ใน tree ทั้งหมด แม้แต่ตัวที่ไม่ได้ขยับเลยตั้งแต่เฟรมที่แล้ว หัวข้อ 5 จะแก้ปัญหานี้

5. Dirty flag: recompute เฉพาะที่ขยับจริง ๆ เท่านั้น

วัตถุส่วนใหญ่ใน level ไม่ขยับในเฟรมส่วนใหญ่ — เสาไฟ ก้อนหิน ตึก ต้นไม้ (แบบต้นไม้จริง ๆ ไม่ใช่ data structure) ทั้งหมดนี้อยู่นิ่ง ๆ ตลอดไปหลังจาก level โหลดเสร็จ การ walk ผ่านมันทุกตัวทุกเฟรมเพื่อ recompute world matrix ที่ออกมา เหมือนเดิม กับเฟรมที่แล้วคือการเสียแรงเปล่า ๆ ทางแก้คือ dirty flag: บิตเดียวต่อ node ที่บอกว่า "world matrix ของฉันอาจจะผิดแล้ว — recompute ก่อนที่ใครจะมาอ่านมันนะ"

มีสองกฎที่ทำให้ dirty flag ถูกต้อง:

struct Node {
    Mat3 local;
    Mat3 world;
    bool dirty = true;
    Node* parent = nullptr;
    std::vector<Node*> children;

    void setLocal(const Mat3& newLocal) {
        local = newLocal;
        markDirty();
    }

    void markDirty() {
        if (dirty) return;              // already dirty -- children were already marked too, stop early
        dirty = true;
        for (Node* child : children)
            child->markDirty();
    }

    Mat3& getWorld() {
        if (dirty) {
            world = parent ? mul(parent->getWorld(), local) : local;
            dirty = false;
        }
        return world;
    }
};

บรรทัด if (dirty) return; ข้างใน markDirty เป็น optimization เล็ก ๆ แต่สำคัญ: dirty flag แพร่กระจาย ลงล่าง เท่านั้น ดังนั้นถ้า node ตัวหนึ่ง dirty อยู่แล้ว descendant ทุกตัวของมันก็ต้อง dirty อยู่แล้วเหมือนกัน (มีการเปลี่ยนแปลงก่อนหน้านี้ผลัก flag ลงไปทั้งสายนั้นแล้ว) — การ walk ลงไปอีกครั้งก็แค่ไป mark node ที่ mark ไว้แล้วซ้ำ ไม่ได้ประโยชน์อะไรเพิ่ม

Frame starts: everything clean (C) Body(C) -- Arm(C) -- Hand(C) -- Sword(C) Gameplay code calls arm.setLocal(...) to raise the arm: Body(C) -- Arm(D) -- Hand(D) -- Sword(D) ^ changed here dirty PROPAGATES DOWN to every descendant Later, rendering code calls sword.getWorld(): sword dirty -> needs hand.getWorld() hand dirty -> needs arm.getWorld() arm dirty -> needs body.getWorld() body CLEAN -> return cached world instantly, no work arm recomputes world, clears its own dirty flag hand recomputes world, clears its own dirty flag sword recomputes world, clears its own dirty flag body.world was never touched -- it was already correct.

getWorld() ถูกเขียนให้ pull ข้อมูลตามความต้องการ (on demand): ไม่มีอะไรถูก recompute จนกว่าจะมี system อื่นมาขอ world matrix จริง ๆ และพอคำนวณเสร็จแล้ว ผลลัพธ์ก็จะถูก cache ไว้จนกว่าจะมีอะไรมา mark dirty อีกครั้ง ถ้าผ่านไปทั้งเฟรมโดยไม่มีใครเรียก sword.getWorld() เลย world matrix ของ sword ก็จะไม่ถูก recompute ในเฟรมนั้นเลย — งานที่ประหยัดได้จริง วัดได้จริง แถมได้มาฟรี ๆ

Tip dirty flag เปลี่ยน "ต้นทุนที่แปรผันตาม tree ทั้งหมด" ให้กลายเป็น "ต้นทุนที่แปรผันตามสิ่งที่เปลี่ยนไปจริง ๆ" ใน level ที่มี static prop 50,000 ชิ้นกับตัวละครที่เคลื่อนไหว 200 ตัว นั่นคือความต่างระหว่างการ walk 50,200 node กับการ walk แค่ไม่กี่ร้อย node ในทุก ๆ เฟรม

6. ทำไมการ recompute ทุกอย่างทุกเฟรมถึง scale ไม่ได้ และ engine batch การอัปเดตยังไง

การ walk แบบ recursive ธรรมดาในหัวข้อ 4 มีต้นทุนเวลาแปรผันตาม จำนวน node ทั้งหมด ใน tree ทุกเฟรม ไม่ว่าจริง ๆ แล้วมีกี่ตัวที่ขยับ dirty flag ในหัวข้อ 5 แก้ปัญหาการ recompute ที่เสียเปล่าไปได้ แต่มันเองก็ยังไม่ได้แก้ต้นทุนที่ซ่อนอยู่อีกอย่างหนึ่ง: ถ้าโค้ด gameplay เรียก getWorld() กระจัดกระจายอยู่ใน system ต่าง ๆ นับสิบใน frame เดียว (physics ถามก่อน แล้วก็ animation แล้วก็ rendering แล้วก็ audio) แต่ละการเรียกก็อาจไปกระตุ้นให้เกิดการ recompute แบบ recursive ชุดเล็ก ๆ ขึ้นไปตามสาย parent ของมันเอง — node ที่อยู่ใกล้ root ของสายที่ยุ่ง ๆ อาจถูกแตะ เช็ค แล้วเช็คซ้ำ โดย system ที่ไม่เกี่ยวข้องกันหลายตัวก่อนที่เฟรมจะจบ

engine ระดับ production หลีกเลี่ยงปัญหานี้ด้วยการ batch: แทนที่จะ recompute world matrix ทันทีที่มีคนขอ พวกมันจะรวบรวม node ที่ถูก mark dirty ระหว่างเฟรมเข้าเป็น list เดียว แล้วประมวลผล list ทั้งหมดนั้นในรอบเดียวโดยเฉพาะ — มักจะทำก่อน system ที่ต้องการ world matrix สุดท้ายจริง ๆ (culling, rendering, physics broad-phase) จะรัน มีเทคนิคที่ใช้จริงอยู่ไม่กี่แบบ:

NAIVE: recompute cost every frame = ALL nodes (section 4) DIRTY FLAG: recompute cost every frame = CHANGED nodes (section 5) BATCHED: the above, done as ONE pass over a sorted list, instead of scattered recursive calls from many systems 50,000 static props: 0 nodes touched per frame (baked once, never dirty again) 200 moving actors: ~200-1000 nodes touched (only these enter the dirty list)

รูปแบบโดยรวมไม่ได้เปลี่ยนไปเลย — คุณยัง walk parent ก่อน children เหมือนเดิมในระดับพื้นฐาน — แต่การ batch เปลี่ยน "จำนวนการ recompute เล็ก ๆ ที่กระจัดกระจายและคาดเดาไม่ได้ ซึ่งถูกกระตุ้นจากทั่วทั้ง codebase" ให้กลายเป็น "รอบเดียวที่คาดเดาได้ เรียงลำดับ รันครั้งเดียว ณ จุดที่รู้แน่นอนในเฟรม" ความคาดเดาได้นี้สำคัญพอ ๆ กับงานที่ประหยัดได้จริง มันคือสิ่งที่ทำให้ multithread การอัปเดตทำได้อย่างปลอดภัย และทำให้การ profile ต้นทุนของ "การอัปเดต transform" เป็นตัวเลขเดียวที่วัดได้ แทนที่จะเป็นตัวเลขเล็ก ๆ หลายสิบตัวที่ซ่อนอยู่ใน system ที่ไม่เกี่ยวข้องกัน

7. Reparent โดยไม่ teleport: รักษา world position ไว้ตอนเปลี่ยน parent

บางครั้งวัตถุก็ต้องเปลี่ยน parent ตอน runtime — ดาบถูกเก็บเข้าฝัก (ย้ายจากมือไปที่ฝักดาบบนหลัง) ไอเทมที่เก็บได้ย้ายจากพื้นไปที่ socket ใน inventory ของ player ผู้โดยสารก้าวลงจากแท่นที่กำลังเคลื่อนที่ไปยืนบนพื้นแข็ง ถ้าคุณแค่เปลี่ยน pointer ของ parent แล้วปล่อย local transform ของ node ไว้เหมือนเดิม วัตถุจะ teleport ให้เห็นชัด ๆ เพราะ world = mul(newParent.world, local) ตอนนี้เอา local matrix ตัวเดิมไปคูณกับ parent matrix ที่ต่างไปจากเดิมโดยสิ้นเชิง

การจะ reparent วัตถุโดย ไม่ ให้มันขยับ ต้องรักษา world transform ไว้คงที่ แล้วไปแก้หา local transform ใหม่แทน กฎนี้ได้มาตรง ๆ จากสูตรในหัวข้อ 3: ถ้า oldWorld = mul(newParent.world, newLocal) ต้องเป็นจริง การคูณทั้งสองข้างด้วย inverse ของ newParent.world ก็จะแยก newLocal ออกมาได้:

newLocal = mul(invertAffine(newParentWorld), oldWorld)

ตรงนี้ต้องใช้ matrix inverse — matrix ที่ยกเลิกผลของ matrix อีกตัวหนึ่ง ทำให้ mul(M, invertAffine(M)) ได้ identity ออกมา สำหรับ matrix แบบ affine สไตล์ TRS (แถวล่างสุดเป็น 0, 0, 1 เสมอ ตรงกับ matrix ทุกตัวที่บทนี้สร้างขึ้นมา) inverse มีสูตรสั้น ๆ ตรง ๆ — ไม่ต้องใช้ general 3x3 inverse เลย:

#include <algorithm>   // std::find

// Node here is the dirty-flag version from section 5: parent pointer,
// children vector, getWorld(), markDirty().

Mat3 invertAffine(const Mat3& M) {
    float a = M.m[0][0], b = M.m[0][1], tx = M.m[0][2];
    float c = M.m[1][0], d = M.m[1][1], ty = M.m[1][2];

    float det = a * d - b * c;
    float invDet = 1.0f / det;    // det == 0 means M has zero scale on some axis -- not invertible

    Mat3 R{};
    R.m[0][0] =  d * invDet;  R.m[0][1] = -b * invDet;  R.m[0][2] = (-d * tx + b * ty) * invDet;
    R.m[1][0] = -c * invDet;  R.m[1][1] =  a * invDet;  R.m[1][2] = ( c * tx - a * ty) * invDet;
    R.m[2][0] = 0;            R.m[2][1] = 0;            R.m[2][2] = 1;
    return R;
}

void reparentKeepWorld(Node& node, Node& newParent) {
    Mat3 oldWorld = node.getWorld();                              // world transform BEFORE the switch

    auto& siblings = node.parent->children;
    siblings.erase(std::find(siblings.begin(), siblings.end(), &node));
    newParent.children.push_back(&node);
    node.parent = &newParent;

    node.local = mul(invertAffine(newParent.getWorld()), oldWorld); // new local that reproduces the same world
    node.markDirty();
}

ลองไล่ดูกับ sword จากหัวข้อ 3 และ 4 ก่อน reparent sword เป็น child ของ hand และ world position ของมันคือ (2, 3) ทีนี้ reparent มันไปไว้ใต้ body โดยตรง (ข้าม arm กับ hand ไปเลย) — world matrix ของ body เป็น translation ธรรมดาไปที่ (5, 0) ไม่มีการหมุน ดังนั้น inverse ของมันก็แค่ translate(-5, 0) ไล่ mul(invertAffine(bodyWorld), swordWorld) ด้วยมือจะได้ local translation ใหม่เป็น (-3, 3) (พร้อมกับการหมุน 90 องศาที่สะสมมาจาก arm เดิม ถูก bake เข้าไปใน local matrix ของมันเองโดยตรง เพราะการหมุนนั้นเป็นส่วนหนึ่งของสิ่งที่ "world" หมายถึงเมื่อครู่นี้ และการ reparent ต้องรักษามันไว้)

std::cout << "sword local before: (1, 0)\n";
Mat3 newLocal = mul(invertAffine(bodyWorld), swordWorld);
Vec2 newLocalPos = transformPoint(newLocal, {0, 0});
std::cout << "sword local after:  (" << newLocalPos.x << ", " << newLocalPos.y << ")\n";

Mat3 checkWorld = mul(bodyWorld, newLocal);
Vec2 checkPos = transformPoint(checkWorld, {0, 0});
std::cout << "sword world after:  (" << checkPos.x << ", " << checkPos.y << ")\n";
sword local before: (1, 0)
sword local after:  (-3, 3)
sword world after:  (2, 3)

ตัวเลข local เปลี่ยนไปเยอะมาก — จาก (1, 0) เป็น (-3, 3) — เพราะตอนนี้ sword ถูกวัดเทียบกับ parent ที่ต่างไปโดยสิ้นเชิงและไม่มีการหมุน แต่ world position ซึ่งเป็นสิ่งเดียวที่ใครจะมองเห็นบนจอจริง ๆ ยังคงเป็น (2, 3) เป๊ะ ไม่ถูกแตะเลย นี่คือประเด็นทั้งหมด: การ reparent คำนวณ local ใหม่เพื่อให้ world คงที่

Common mistake ลืมไปว่าวิธีนี้แก้ทั้ง rotation กับ scale ไม่ใช่แค่ position เท่านั้น การ reparent ที่แก้แค่ translation column แล้วปล่อย rotation/scale เดิมไว้เฉย ๆ จะทำให้วัตถุอยู่ตำแหน่งถูกต้องก็จริง แต่หันผิดทาง (หรือขนาดผิด) ทันทีที่ parent ใหม่มี rotation หรือ scale เป็นของตัวเอง การแก้ทั้ง matrix พร้อมกันทีเดียวแบบที่ invertAffine ทำ จะแก้ทั้ง position, rotation, และ scale ไปพร้อมกันในขั้นตอนเดียว

8. Storage layout: tree ของ pointer เทียบกับ flat array ที่เรียงลำดับแล้ว

struct Node ในหัวข้อ 4 — pointer ไปหา parent, std::vector ของ pointer ไปหา children — เป็น scene graph เวอร์ชันที่เข้าใจง่ายที่สุด และมันคือ tree ของ pointer ตัวจริง: node แต่ละตัวเป็น heap allocation แยกกัน (บทเรื่อง heap: new Node หรืออะไรก็ตามที่ allocator ของ engine คุณทำอยู่เบื้องหลัง) กระจัดกระจายอยู่ตรงไหนก็ได้ที่ allocator บังเอิญวางไว้ การ walk tree นั้นหมายถึงการไล่ตาม pointer กระโดดไปที่ address ที่คาดเดาไม่ได้ใน memory อ่าน field สองสามตัว แล้วไล่ตาม pointer ตัวถัดไป — และ CPU สมัยใหม่ทำ "กระโดดไป address สุ่ม ๆ" ได้ช้ากว่า "อ่านไบต์ถัดไปต่อจากที่เพิ่งอ่านไป" อย่างมาก (เนื้อหาเรื่อง cache กับ memory layout ในบทก่อน ๆ อธิบายเหตุผลไว้ชัดเจน)

TREE OF POINTERS -- nodes scattered across the heap address 0x1000: [Body] parent->0x4400 children->{0x2a10} address 0x2a10: [Arm] parent->0x1000 children->{0x9f30} address 0x9f30: [Hand] parent->0x2a10 children->{0x0770} address 0x0770: [Sword] parent->0x9f30 children->{} Walking Body -> Arm -> Hand -> Sword jumps all over memory -- the CPU cannot predict where the next node lives until it has already read the pointer to it.

อีกทางเลือกหนึ่งคือ layout แบบ flat array: ทิ้ง node allocation แยกตัวไปเลย แล้วเก็บ array ก้อนใหญ่ต่อเนื่องกันของ local matrix หนึ่งก้อน, world matrix หนึ่งก้อน, และ array ของ parent index แบบ integer ธรรมดาแทน pointer กฎเดียวที่ทำให้วิธีนี้ใช้ได้คือ: เรียง array ให้ index ของ parent น้อยกว่า index ของ children เสมอ — "parent มาก่อน child" แบบเรียงลำดับตรง ๆ ไม่ใช่แค่รูปร่างของ tree เท่านั้น

#include <vector>

struct FlatScene {
    std::vector<Mat3> local;
    std::vector<Mat3> world;
    std::vector<int>  parentIndex;   // -1 means "no parent, this is a root"
};

// index 0: root        parentIndex[0] = -1
// index 1: body        parentIndex[1] = 0
// index 2: arm         parentIndex[2] = 1
// index 3: hand        parentIndex[3] = 2
// index 4: sword       parentIndex[4] = 3

void updateAllWorldTransforms(FlatScene& scene) {
    for (size_t i = 0; i < scene.local.size(); i++) {
        int p = scene.parentIndex[i];
        scene.world[i] = (p == -1) ? scene.local[i] : mul(scene.world[p], scene.local[i]);
    }
}

ไม่มี recursion ไม่มีการไล่ตาม pointer ไม่มี function-call overhead ต่อ node เลย — มีแค่ loop ตรง ๆ วนผ่าน array ธรรมดาสามก้อน การเรียงแบบ "parent มาก่อน child" คือสิ่งที่ทำให้ scene.world[p] ถูกต้องอยู่แล้วเสมอตอนที่ index i มาอ่านมัน: เพราะ p < i รับประกันได้เสมอสำหรับทุก node และ loop วน index จากน้อยไปมาก การวนของ p จึงเกิดขึ้นก่อนเสมอใน loop เดียวกันนี้

FLAT ARRAY -- one contiguous block per array, sorted parent-before-child index: 0 1 2 3 4 local[]: [root] [body] [arm] [hand] [sword] world[]: [ ? ] [ ? ] [ ? ] [ ? ] [ ? ] -> filled left to right parentIdx[]: -1 0 1 2 3 Reading local[i] and parentIdx[i] and writing world[i] all touch memory right next to the last access -- the CPU's prefetcher can see this pattern coming and load ahead, instead of guessing.

ทำไม flat ถึงชนะ: พฤติกรรม cache กับ parallelism

มีข้อดีสองอย่างแยกกันที่ได้มาจากการเปลี่ยน layout แบบเดียวกันนี้:

การสร้าง array ที่เรียงลำดับแล้วตั้งแต่แรกมักทำด้วยการ walk tree แบบ breadth-first (ไปเยี่ยม root ก่อน แล้วค่อยไป children ทั้งหมดของมัน แล้วค่อยไป children ของ children เหล่านั้น ไปเรื่อย ๆ) — การกำหนด array index ตามลำดับการเยี่ยมแบบนี้จะรับประกันโดยอัตโนมัติว่า index ของ parent น้อยกว่า index ของ children เสมอ เพราะ parent จะถูกเยี่ยมก่อน children อย่างน้อยหนึ่ง level เสมอ

Tip นี่คือเหตุผลที่ engine บางตัว (รวมถึงระบบ transform ของ Unity DOTS/ECS) เก็บ transform เป็นข้อมูลแบบ flat ต่อเนื่องกันอยู่เบื้องหลัง ทั้ง ๆ ที่ Editor ยังโชว์ tree ที่เป็นมิตรกับมนุษย์ให้ดูใน Hierarchy window อยู่ tree มีไว้สำหรับมนุษย์ ส่วน flat array มีไว้สำหรับ CPU

9. ปัญหา non-uniform scale: rotation กับ shear เข้ากันไม่ได้

uniform scale คูณทุกแกนด้วยตัวเลขเดียวกัน (scale(2, 2): ใหญ่ขึ้นสองเท่า รูปร่างเดิม) non-uniform scale คูณแกนต่างกันด้วยตัวเลขต่างกัน (scale(3, 1): กว้างขึ้นสามเท่า สูงเท่าเดิม — คือการยืด) uniform scale ผสมกับ rotation ไม่ว่าลำดับไหนก็ตาม จะได้ผลลัพธ์เป็น rotation-บวก-scale ที่สะอาดเสมอ แต่ non-uniform scale ไม่เป็นแบบนั้น: ผสมมันกับ rotation ที่อยู่ข้างใต้มันใน hierarchy แล้วผลลัพธ์อาจ shear (บิดมุมของรูปทรง เหมือนดันด้านบนของสี่เหลี่ยมจัตุรัสไปด้านข้างจนกลายเป็นสี่เหลี่ยมด้านขนาน) — ความบิดเบี้ยวแบบนี้ position/rotation/scale ธรรมดาอธิบายไม่ได้เลย

นี่คือกลไกจริง ๆ ที่เช็คด้วยมือได้ ลอง scale (2, 1) กับ rotation 45 องศา แล้วคูณมันทั้งสองลำดับที่เป็นไปได้ matrix แบบ rotation-บวก-scale ที่สะอาดจะมี column ที่ตั้งฉากกัน เสมอ (column แรกกับ column ที่สอง ถ้ามองเป็น vector 2D จะทำมุมกัน 90 องศาพอดี) — ดังนั้นการเช็ค dot(column0, column1) คือการทดสอบ shear ได้ตรง ๆ: ศูนย์แปลว่าสะอาด ไม่ใช่ศูนย์แปลว่าโดน shear

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };
struct Mat3 { float m[3][3]; };

Mat3 mul(const Mat3& A, const Mat3& B) {
    Mat3 R{};
    for (int row = 0; row < 3; row++)
        for (int col = 0; col < 3; col++) {
            float sum = 0;
            for (int k = 0; k < 3; k++)
                sum += A.m[row][k] * B.m[k][col];
            R.m[row][col] = sum;
        }
    return R;
}

Mat3 scaleMatrix(float sx, float sy) {
    return { { {sx, 0, 0}, {0, sy, 0}, {0, 0, 1} } };
}

Mat3 rotationMatrix(float degrees) {
    float radians = degrees * 3.14159265f / 180.0f;
    float c = std::cos(radians);
    float s = std::sin(radians);
    return { { {c, -s, 0}, {s, c, 0}, {0, 0, 1} } };
}

float shearAmount(const Mat3& M) {   // dot product of the two 2x2 columns; 0 = no shear
    Vec2 col0 = { M.m[0][0], M.m[1][0] };
    Vec2 col1 = { M.m[0][1], M.m[1][1] };
    return col0.x * col1.x + col0.y * col1.y;
}

int main() {
    Mat3 S = scaleMatrix(2.0f, 1.0f);
    Mat3 R = rotationMatrix(45.0f);

    Mat3 rotateInsideScale = mul(S, R);   // a CHILD's rotation, under a non-uniformly SCALED parent
    Mat3 scaleInsideRotate = mul(R, S);   // scale applied inside a rotated frame -- one object's own S then R

    std::cout << "shear of mul(S, R) = " << shearAmount(rotateInsideScale) << "\n";
    std::cout << "shear of mul(R, S) = " << shearAmount(scaleInsideRotate) << "\n";
}

Output:

shear of mul(S, R) = -1.5
shear of mul(R, S) = 0

mul(R, S) — scale ก่อนแล้วค่อย rotate ทั้งคู่ทำกับวัตถุ ตัวเดียวกัน — ออกมาสะอาดสมบูรณ์ ไม่มี shear เลย: นั่นก็แค่ transform แบบธรรมดา "scale มันก่อน แล้วค่อยหมุนทั้งรูปทรงที่ scale แล้ว" ซึ่งตรงกับที่หัวข้อ 8 ของบทที่ 2.1 แนะนำไว้เป๊ะ (model = T * R * S) แต่ mul(S, R) คือสถานการณ์ scene-graph ตรงตามบทนี้เป๊ะ: parent ที่มี non-uniform scale S และ child ที่มี rotation R ของตัวเองถูกใช้ ข้างใน พื้นที่ของ parent ที่ถูกยืดไปแล้ว ค่า shear ที่ไม่ใช่ศูนย์ (-1.5) พิสูจน์ว่ารูปทรงของ child บิดเบี้ยวจริง ๆ — มุมฉากของมันไม่ใช่ 90 องศาอีกต่อไปใน world space และไม่มี position/rotation/scale ชุดไหนบน child เพียงตัวเดียวที่จะแก้มันคืนได้

UNIFORM scale parent + rotated child NON-UNIFORM scale parent + rotated child (clean: still a rectangle) (sheared: corners are no longer 90 degrees) +------+ +--------+ / / / / / / <- just rotated + bigger / / <- slanted, not +------+ +--------+ just rotated Both squares were rotated by the same 45 degrees. Only the one under NON-UNIFORM scale lost its right angles.

นี่คือเหตุผลที่ engine เตือนเรื่อง non-uniform scale บน parent ที่มี children ที่หมุนได้ — อย่าง Unity เองก็เปิด transform.lossyScale ให้ใช้ (world-space scale แบบประมาณ และชื่อของมันก็เป็นการเตือนโดยตั้งใจ: "lossy" แปลว่าข้อมูลสูญหายไป เพราะ matrix ที่โดน shear แล้วไม่สามารถแยกกลับเป็น scale ที่แม่นยำได้เสมอไป) และ physics engine หลายตัวก็ปฏิเสธที่จะสร้าง collider ที่แม่นยำภายใต้ non-uniform scale ไปเลย ทางแก้ในทางปฏิบัติง่าย ๆ คือ: เก็บ non-uniform scale ไว้ที่ node แบบ leaf เท่านั้น (mesh เดี่ยว ๆ ที่ไม่มี children ที่หมุนได้เป็นของตัวเอง) แทนที่จะเอาไปไว้ที่ node ระหว่างทางใน hierarchy หรือไม่ก็ bake การยืดแบบ non-uniform เข้าไปใน mesh data โดยตรงแทนที่จะปล่อยให้มันอยู่บน Transform แบบ live

10. Transform component ของ Unity: local เทียบกับ world, GetChild, และ SetParent

ทุกอย่างในบทนี้คือสิ่งที่ Transform component ในตัวของ Unity ทำอยู่จริง ๆ แค่เปลี่ยนชื่อให้เป็นมิตรขึ้น GameObject ทุกตัวมี Transform ตัวเดียวเสมอ และ Transform ทุกตัวมี parent ได้ (ไม่บังคับ) — Hierarchy window ใน Editor คือภาพสด ๆ ของ scene graph ที่บทนี้สร้างมาทั้งบทด้วยมือ

using UnityEngine;

public class SheathSword : MonoBehaviour
{
    public Transform hand;    // sword's current parent, at world (2, 2), rotated 90 degrees
    public Transform body;    // sword's new parent, at world (5, 0), no rotation
    public Transform sword;   // localPosition = (1, 0) while under hand

    void Start()
    {
        Debug.Log("before: sword.position = " + sword.position);       // world stays the same either way
        Debug.Log("before: sword.localPosition = " + sword.localPosition);

        sword.SetParent(body, true);   // true = worldPositionStays

        Debug.Log("after:  sword.position = " + sword.position);       // unchanged: still (2, 3)
        Debug.Log("after:  sword.localPosition = " + sword.localPosition); // recomputed: now (-3, 3)

        for (int i = 0; i < body.childCount; i++)
            Debug.Log("body's child " + i + ": " + body.GetChild(i).name);
    }
}

ตั้ง scene ด้วยตัวเลขชุดเดิมเป๊ะจากหัวข้อ 3, 4, และ 7 — hand อยู่ที่ world (2, 2) พร้อมการหมุน 90 องศาของ arm, sword อยู่ที่ local (1, 0) ใต้มัน, body อยู่ที่ world (5, 0) ไม่มีการหมุน — แล้ว Console จะพิมพ์ sword.position ที่ยังอยู่ที่ (2, 3) ตลอดการ reparent ในขณะที่ sword.localPosition กระโดดจาก (1, 0) ไปเป็น (-3, 3) ตรงกับการไล่ด้วย C++ ในหัวข้อ 7 เป๊ะ สังเกตด้วยว่า transform.parent = someTransform (assign property ตรง ๆ แทนที่จะเรียก method) จะทำงานเหมือน SetParent(someTransform, true) — world position จะถูกรักษาไว้โดย default ทั้งสองแบบ คุณต้องส่ง false ให้ชัดเจนถึงจะข้ามคณิตศาสตร์นี้ได้

Common mistake อ่าน transform.position ใน Awake() ของวัตถุที่ parent ของมันยังรัน Awake() ของตัวเองไม่เสร็จ Unity ไม่รับประกันว่า Awake() จะรัน parent ก่อน child ข้าม GameObject ที่ต่างกัน ดังนั้น transform ของ parent อาจยังเป็นค่า default อยู่ตอนที่ child มาขอ world position ของตัวเอง — คือบั๊กแบบเดียวกับที่กฎ "parent ต้อง clean ก่อน child จะอ่าน" ในหัวข้อ 5 ถูกออกแบบมาป้องกัน แค่ที่นี่เป็น script execution order ของ Unity ไม่ใช่ dirty flag ที่ทำให้พลาดได้ ถ้าค่าที่แน่นอนสำคัญตั้งแต่ช่วงแรกขนาดนี้ ให้ใช้ Start() แทน หรือไม่ก็ตั้งค่า transform ของ parent ให้เสร็จก่อนที่ child จะมาอ่านมัน

11. Glossary

12. Exercises

Exercise 1 scene หนึ่งมี tree แบบนี้: Root มี children สองตัวคือ Body กับ Torch Body มี child หนึ่งตัวคือ Arm ซึ่งมี child หนึ่งตัวคือ Hand ทั้งห้า node เริ่มต้นแบบ clean (ไม่ dirty) โค้ด gameplay เรียก arm.setLocal(...) เพื่อแกว่งแขน (a) ให้ list node ทุกตัวที่กลายเป็น dirty (b) ถ้า renderer เรียก torch.getWorld() ต่อ จะมี matrix ไหนถูก recompute ไหม เพราะอะไร (c) ถ้า renderer เรียก hand.getWorld() ต่อ ให้ list mul(...) ทุกตัวที่รันจริง ๆ ตามลำดับ
Show answer

(a) arm.setLocal(...) mark Arm ให้ dirty แล้ว propagate dirty ลงล่าง ไปหา descendant ของมัน: Hand ก็กลายเป็น dirty ด้วย Root, Body, และ Torch ยังคง clean — dirty ไม่เคยแพร่ขึ้นไปหา parent หรือแพร่ไปด้านข้างหา sibling เลย

(b) ไม่มี Torch เป็น child ของ Root ซึ่งเป็นสายที่แยกออกไปจาก Body/Arm/Hand โดยสิ้นเชิง และมันไม่เคยถูก mark dirty เลย torch.getWorld() เห็นว่า dirty == false แล้วส่ง matrix ที่ cache ไว้กลับไปทันที — นี่คือประโยชน์ตรง ๆ จาก tip ในหัวข้อ 5: สายที่ไม่เกี่ยวข้องกันไม่เสียต้นทุนอะไรเลย

(c) hand.getWorld() เจอว่าตัวเอง dirty เลยต้องใช้ arm.getWorld() ก่อน arm.getWorld() ก็เจอว่าตัวเอง dirty เหมือนกัน เลยต้องใช้ body.getWorld() — แต่ Body clean อยู่แล้ว การเรียกนั้นก็ return กลับทันทีโดยไม่มี mul เลย จากนั้น arm.getWorld() รัน mul(body.world, arm.local) แล้วเคลียร์ dirty flag ของตัวเอง สุดท้าย hand.getWorld() รัน mul(arm.world, hand.local) แล้วเคลียร์ flag ของตัวเอง รวมแล้ว: mul แค่สองครั้งพอดี ไม่ใช่สี่หรือห้าครั้ง

Exercise 2 ไอเทมที่เก็บได้ X ตอนนี้เป็น child ของ GroundAnchor ซึ่ง world matrix ของมันเป็น translation ธรรมดาไปที่ (2, 0) (ไม่มีการหมุน) local matrix ของ X เป็น translation ธรรมดาไปที่ (3, 4) player เก็บมันขึ้นมา แล้วมันต้อง reparent ไปที่ HandSocket ซึ่ง world matrix เป็นการหมุน 90 องศาธรรมดา (ไม่มี translation) — ให้ถือว่า cos(90) = 0 และ sin(90) = 1 พอดีสำหรับ exercise นี้ ใช้ newLocal = mul(invertAffine(newParentWorld), oldWorld) คำนวณ world position ของ X ก่อน reparent และ local position ใหม่ของมัน หลัง reparent จากนั้นตรวจสอบ: mul(HandSocket.world, newLocal) ให้ world position กลับมาเหมือนที่เริ่มต้นไหม
Show answer

X.world ก่อน reparent: mul(GroundAnchor.world, X.local) คือ translate(2,0) ที่ทำต่อจาก translate(3,4) — world position (2+3, 0+4) = (5, 4)

HandSocket.world เป็น rotation ล้วน ๆ R(90) = [[0,-1],[1,0]] inverse ของมันคือ transpose ของมันเอง (จริงเสมอสำหรับ rotation ล้วน ๆ): invertAffine(R(90)) = R(-90) = [[0,1],[-1,0]]

newLocal = mul(R(-90), X.world) เอา R(-90) ไปใช้กับจุด (5, 4): x' = 0*5 + 1*4 = 4, y' = -1*5 + 0*4 = -5 ดังนั้น local position ใหม่ของ X คือ (4, -5) โดยส่วน rotation ของ newLocal เท่ากับ R(-90) (local matrix ของไอเทมตอนนี้พก rotation -90 องศาติดตัวไปด้วย ทั้ง ๆ ที่ไม่มีใครหมุนมันโดยตรงเลย — rotation นั้นเป็นแค่ผลข้างเคียงจากการย้ายไปอยู่ใต้ parent ที่หมุนอยู่)

ตรวจสอบ: mul(HandSocket.world, newLocal) เอา R(90) ไปใช้กับ (4, -5): x' = 0*4 - 1*(-5) = 5, y' = 1*4 + 0*(-5) = 4 ได้ (5, 4) กลับมา — world position เดิมเป๊ะที่ X เริ่มต้นไว้ การ reparent ใช้ได้ผล

Exercise 3 ใช้ shearAmount จากหัวข้อ 9 (dot(column0, column1) ของส่วน rotation/scale แบบ 2x2) และให้ cos(30) ประมาณ 0.866, sin(30) = 0.5: (a) คำนวณ mul(scaleMatrix(2, 2), rotationMatrix(30)) — parent ที่มี uniform scale กับ child ที่หมุนอยู่ — แล้วหา shear amount ของมัน (b) คำนวณ mul(scaleMatrix(3, 1), rotationMatrix(30)) — parent ที่มี non-uniform scale กับ child ที่หมุนตัวเดิม — แล้วหา shear amount ของมัน (c) แบบไหนปลอดภัยที่จะใช้กับ parent ที่มี child หมุนอยู่ และแบบไหนจะเบี้ยวให้เห็นชัด ๆ
Show answer

(a) scaleMatrix(2,2) คือ [[2,0],[0,2]] คูณกับ R(30) = [[0.866,-0.5],[0.5,0.866]] ได้ column col0 = (1.732, 1) และ col1 = (-1, 1.732) dot = 1.732*(-1) + 1*1.732 = -1.732 + 1.732 = 0 ไม่มี shear

(b) scaleMatrix(3,1) คือ [[3,0],[0,1]] คูณกับ R(30) ตัวเดิม ได้ column col0 = (2.598, 0.5) และ col1 = (-1.5, 0.866) dot = 2.598*(-1.5) + 0.5*0.866 = -3.897 + 0.433 = -3.464 ค่านี้ไม่ใช่ศูนย์ — โดน shear

(c) parent ที่มี uniform scale (2, 2) ปลอดภัย — rotation ข้างใต้มันไม่ว่ามุมไหนก็ยังเป็น rotation-บวก-scale ที่สะอาดเสมอ ส่วน parent ที่มี non-uniform scale (3, 1) จะทำให้ child ที่หมุนอยู่เบี้ยวให้เห็นชัด ๆ ตรงกับความบิดเบี้ยวแบบ "ละลาย" ในไดอะแกรมของหัวข้อ 9 เป๊ะ — นี่คือกรณีที่ warning ของ lossyScale ใน Unity มีไว้เตือน

scene graph ก็ไม่ใช่อะไรมากไปกว่ากฎเดียวของหัวข้อ 3 — childWorld = mul(parentWorld, childLocal) — ที่ถูกใช้ซ้ำ ๆ ทีละ node ตามลำดับที่ประมวลผล parent ให้เสร็จก่อนแตะ children เสมอ ที่เหลือทั้งหมดในบทนี้คือเรื่องของการทำสิ่งนั้นให้ถูกและ scale ได้: dirty flag ที่ทำให้สายที่ไม่เปลี่ยนไม่เสียต้นทุนอะไร, การ batch ที่ทำให้การอัปเดตทั้งเฟรมเกิดขึ้นในรอบเดียวที่คาดเดาได้, คณิตศาสตร์ reparenting ที่ทำให้วัตถุเปลี่ยนสายได้โดยไม่ขยับให้เห็น, และ flat array layout ที่เรียงลำดับแล้วซึ่งทำให้ CPU walk node เป็นพัน ๆ ตัวได้ด้วยความเร็ว memory เต็มที่แทนที่จะไล่ตาม pointer จับตาดู non-uniform scale ที่อยู่เหนือ child ที่หมุนอยู่ให้ดี แล้วคุณก็จะมีทุกอย่างที่ Transform component ของ Unity — และของทุก engine อื่น ๆ — ถูกสร้างขึ้นมาจากมัน

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