ציור שטח באמצעות שדה גבהים קשור למשחקים כגון Comanche, אך מאמר זה הוא שחזור לימודי ולא תיאור מאומת של כל מנוע היסטורי או מעבד יעד. פרויקט הייחוס הקטן VoxelSpace מספק הסבר מועיל לשיטה.
שיטת השטח כאן מטילה שדה גבהים נדגם למקטעים אנכיים במסך במקום לבנות רשת מצולעים כללית. אין פירוש הדבר שכל עצם במשחקים שנזכרו הוצג ללא מצולעים.
אלגוריתם קומפקטי עשוי להיות קל יותר להבנה ממנוע ציור כללי, אך מספר שורות אינו יישום מלא או מבחן ביצועים. לא בוססו כאן מגמה עדכנית ב־Hacker News, השוואת גודל ספריות או תוצאת ביצועים אישית.
נסביר את ההטלה, נבחן את טעויות המקור ונציג רשימת קוד לימודית מתוקנת ל־Canvas. ציור בדפדפן ובדיקות חזותיות לא היו זמינים, ולכן הדוגמה אינה מסומנת כמוכנה לייצור או כבעלת ביצועים מאומתים.
רעיון הליבה: מפות גובה ומפות צבע
הגרסה הזאת משתמשת בשתי מפות דו־ממדיות במידות זהות: דגימת גובה וצבע לכל מיקום. גודל שהוא חזקה של שתיים נוח לחזרה המחזורית באמצעות מסכת הסיביות שלהלן, אך אינו דרישה כללית לציור שדה גבהים.
- מפת הצבע: מרקם דו־ממדי רגיל המכיל את צבעי פני השטח — דשא, אדמה, דרכים או שלג — כפי שהם נראים ממבט ישיר מלמעלה.
- מפת הגובה בגוני אפור מספקת ערכים מ־0 עד 255. המשמעות הפיזית, קנה המידה האנכי וגובה הייחוס הם בחירות של היישום; אפס אינו חייב להיות פני הים ו־255 אינה חייבת להיות פסגת הר.
זהו שדה גבהים, המכונה לעיתים 2.5D: לכל מיקום אופקי יש גובה אחד. הוא אינו יכול לייצג ישירות בליטות תלויות, מערות או כמה משטחי שטח באותו מיקום. ההטלה יוצרת תצוגת פרספקטיבה של המודל המוגבל הזה.
איך האלגוריתם פועל, בלי דוקטורט במתמטיקה
המימוש מפשט את הגאומטריה באמצעות בחירת מודל שטח ומצלמה מוגבלים. אלה אילוצי תכנון מפורשים, לא פתרון כללי למשוואת הציור.
- הדוגמה תומכת בסיבוב אופקי ובגובה מצלמה. הזזת האופק היא רק קירוב להטיה מעלה ומטה; היא אינה מממשת מצלמה מסובבת במלואה או גלגול.
- פני השטח נצפים מנקודת מבט שבה מציירים עמודה אחר עמודה, מהקדמה הקרובה למצלמה אל הרקע הרחוק.
לכל עמודת פיקסלים אנכית במסך, משמאל לימין, המנוע מטיל קרן ממיקום המצלמה אל מפת העולם. ככל שהקרן מתקדמת, דוגמים את הגובה והצבע של המפה בצעדים בדידים.
להלן ההיגיון המאויר של עמודה אנכית אחת במסך, צעד אחר צעד:
1. מתחילים במיקום המצלמה.
2. מתקדמים לאורך קו הראייה של המצלמה למרחק עומק, z.
3. מחשבים את קואורדינטת המפה הדו־ממדית x, y בצעד הזה.
4. מאחזרים את גובה פני השטח h ואת הצבע c ממפת הגובה וממפת הצבע בנקודה x, y.
5. מטילים את הגובה התלת־ממדי למסך באמצעות חלוקה פרספקטיבית:
screen_y = (camera_height - terrain_height) / depth * scale + horizon
6. מציירים קו אנכי בצבע c מה־screen_y שהתקבל עד לנקודה הגבוהה ביותר שצוירה קודם.
7. חוזרים על הפעולה בצעד הבא, רחוק יותר.
לכל עמודה שומרים את ערך y הקטן ביותר שכוסה כבר בידי שטח קרוב יותר. דגימה רחוקה מוסיפה פיקסלים גלויים רק מעל הגבול הזה. המסכה דורשת עבודה וזיכרון, ואינה חוצץ עומק כללי לכל פיקסל ולגאומטריה שרירותית.
בניית מצייר Voxel Space ב־JavaScript
המימוש הלימודי כותב לחוצץ ImageData דרך תצוגת Uint32Array. זהו תכנון שיש למדוד מול פעולות Canvas אחרות, לא הוכחה ש־lineTo או fillRect תמיד איטיות יותר. סדר הבתים המקורי של המערך מטופל בעקביות, בלי להניח ייצוג מספרי אוניברסלי של ABGR.
שלב 1: הגדרת HTML Canvas
חוצץ ציור של 320×240 שומר על היקף הדוגמה מוגבל, אך אינו מבטיח 60 פריימים בשנייה במכשירים ניידים. בלוק הקוד להלן הוא מקטע הדגמה לעמוד בדיקה נפרד, ולא סימון פעיל המוטמע במאמר. הוא כולל מיקוד מקלדת, הוראות וכפתור התחלה והשהיה מפורש.
<!-- Separate demo-page fragment; shown as inert code in the article. -->
<div class="voxel-demo">
<p id="voxel-help">בחרו התחלה ואז מקדו את הקנבס. חצים מניעים או מסובבים; WASD ו־R/F מניחים פריסת מקלדת לטינית;
R/F משנים גובה. השהיה עוצרת את האנימציה.</p>
<button id="toggle" type="button" disabled>התחלה</button>
<p id="status" role="status">טוען מפות הדגמה מקומיות.</p>
<canvas id="screen" width="320" height="240" tabindex="0"
aria-label="הדגמת שטח באמצעות שדה גבהים" aria-describedby="voxel-help">
הדגמה חזותית של שדה גבהים; בקרי המקלדת מתוארים לעיל.
</canvas>
</div>
<style>
.voxel-demo canvas { width: min(100%, 640px); height: auto; image-rendering: pixelated; }
.voxel-demo canvas:focus-visible { outline: 3px solid #1769aa; outline-offset: 3px; }
</style>
<!-- On a separate test page, load the following JS after these elements. -->
שלב 2: טעינת נתוני המפה לזיכרון
השתמשו במפות מהימנות ובעלות רישוי מתאים מאותו מקור, בדיוק בגודל 1024×1024. הטוען מחבר מטפלים לפני src, דוחה שגיאות, חריגת זמן ואי־התאמת מידות, בודק זמינות Canvas ולוכד כשלי קריאת פיקסלים. כשל בטעינת מפה אינו אמור להתחיל אנימציה. הערוץ האדום מספק את ערכי הגובה של מפת האפור המצופה.
// Educational separate-page JS, not executed or benchmarked here.
const canvas = document.getElementById('screen');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is unavailable');
const width = canvas.width, height = canvas.height;
const screenImg = ctx.createImageData(width, height);
const screenBuffer = new Uint32Array(screenImg.data.buffer);
const skyWord = new Uint32Array(new Uint8Array([48, 160, 224, 255]).buffer)[0];
const mapSize = 1024; // Power of two is required by the mask below.
const colorMap = new Uint32Array(mapSize * mapSize);
const heightMap = new Uint8Array(mapSize * mapSize);
function loadMap(path, isHeightMap) {
return new Promise((resolve, reject) => {
const url = new URL(path, location.href);
if (url.origin !== location.origin) {
reject(new Error('Use trusted same-origin maps')); return;
}
const img = new Image();
let settled = false;
const timer = setTimeout(() => finish(new Error('Map load timed out')), 15000);
function finish(error) {
if (settled) return;
settled = true;
clearTimeout(timer);
img.onload = img.onerror = null;
if (error) reject(error); else resolve();
}
img.onerror = () => finish(new Error('Map load failed'));
img.onload = () => {
try {
if (img.naturalWidth !== mapSize || img.naturalHeight !== mapSize)
throw new Error('Map must be 1024 by 1024');
const temp = document.createElement('canvas');
temp.width = temp.height = mapSize;
const tc = temp.getContext('2d');
if (!tc) throw new Error('Canvas 2D is unavailable');
tc.drawImage(img, 0, 0);
const rgba = tc.getImageData(0, 0, mapSize, mapSize).data;
if (isHeightMap) {
for (let i = 0; i < heightMap.length; i++) heightMap[i] = rgba[4 * i];
} else {
// Terrain is opaque in this model. Preserve platform byte order.
for (let i = 3; i < rgba.length; i += 4) rgba[i] = 255;
colorMap.set(new Uint32Array(rgba.buffer, rgba.byteOffset, rgba.length / 4));
}
finish();
} catch (error) { finish(error); }
};
img.src = url.href;
});
}
שלב 3: לולאת הציור המרכזית
המצלמה שומרת מיקום אופקי, גובה ביחידות מפה שרירותיות, סיבוב אופקי, היסט אופק, קנה מידה להטלה ומגבלת עומק קדמי קבועה. היחידות אינן תואמות אוטומטית למטרים או לפני הים.
חשבו סינוס וקוסינוס פעם אחת לפריים ואז בנו את קרן כל עמודה ממיקומה במסך. הקוד עדיין משתמש בחשבון בנקודה צפה. העומק נמדד לאורך ציר המבט קדימה, וקוד התנועה חייב להשתמש באותה מוסכמת כיוון.
const camera = { x: 512, y: 512, height: 120, angle: 0,
horizon: 60, distance: 400, scale_height: 120 };
function render() {
screenBuffer.fill(skyWord);
const sin = Math.sin(camera.angle), cos = Math.cos(camera.angle);
for (let column = 0; column < width; column++) {
const side = ((column + 0.5) / width - 0.5) * 2;
// Forward=(sin,cos), right=(cos,-sin); fixed roughly 90-degree FOV.
const rx = sin + side * cos, ry = cos - side * sin;
let top = height;
for (let z = 1; z < camera.distance && top > 0; z += 1.5) {
const mx = Math.floor(camera.x + rx * z) & (mapSize - 1);
const my = Math.floor(camera.y + ry * z) & (mapSize - 1);
const index = my * mapSize + mx;
const projected = Math.floor((camera.height - heightMap[index]) /
z * camera.scale_height + camera.horizon);
// A sample above the screen may cover the whole remaining column.
const y0 = Math.max(0, Math.min(height, projected));
if (y0 < top) {
for (let y = y0; y < top; y++) screenBuffer[y * width + column] = colorMap[index];
top = y0;
}
}
}
ctx.putImageData(screenImg, 0, 0);
}
שלב 4: הוספת אינטראקטיביות
קלט המקלדת מוגבל לקנבס הממוקד כדי לא להשתלט על הניווט בעמוד כולו. התנועה משתמשת בזמן שחלף ולא במרחק קבוע לכל פריים; שינויי מיקוד או נראות מנקים מקשים מוחזקים. ההתחלה מפורשת והשהיה מבטלת את לולאת האנימציה. בקרי מגע וחלופה מלאה שאינה חזותית אינם חלק מהרשימה להמחשה. מקשי האותיות מניחים פריסת מקלדת לטינית; מקשי החצים זמינים לתנועה ולסיבוב.
const keys = new Set();
const button = document.getElementById('toggle');
const status = document.getElementById('status');
const allowed = new Set(['arrowleft','arrowright','arrowup','arrowdown','w','a','s','d','r','f']);
let running = false, frame = null, previous = null;
canvas.addEventListener('keydown', event => {
const k = event.key.toLowerCase();
if (!allowed.has(k) || event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault(); keys.add(k);
});
canvas.addEventListener('keyup', event => keys.delete(event.key.toLowerCase()));
canvas.addEventListener('blur', () => keys.clear());
function wrap(value) { return ((value % mapSize) + mapSize) % mapSize; }
function pause() {
running = false;
if (frame !== null) cancelAnimationFrame(frame);
frame = null; previous = null; keys.clear(); button.textContent = 'התחלה';
}
function update(time) {
if (!running) return;
const dt = previous === null ? 0 : Math.min((time - previous) / 1000, 0.05);
previous = time;
if (keys.has('arrowleft') || keys.has('a')) camera.angle -= 1.8 * dt;
if (keys.has('arrowright') || keys.has('d')) camera.angle += 1.8 * dt;
const move = Number(keys.has('arrowup') || keys.has('w')) -
Number(keys.has('arrowdown') || keys.has('s'));
camera.x = wrap(camera.x + Math.sin(camera.angle) * move * 120 * dt);
camera.y = wrap(camera.y + Math.cos(camera.angle) * move * 120 * dt);
const climb = Number(keys.has('r')) - Number(keys.has('f'));
camera.height = Math.max(1, Math.min(512, camera.height + climb * 90 * dt));
render();
frame = requestAnimationFrame(update);
}
button.addEventListener('click', () => {
if (running) { pause(); return; }
running = true; previous = null; button.textContent = 'השהיה';
canvas.focus(); frame = requestAnimationFrame(update);
});
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });
async function init() {
try {
await Promise.all([loadMap('color.png', false), loadMap('height.png', true)]);
render(); button.disabled = false; status.textContent = 'מוכן. בחרו התחלה.';
} catch {
pause(); status.textContent = 'לא ניתן לטעון את מפות ההדגמה המהימנות.';
}
}
init();
למה האלגוריתם חשוב היום?
למה ללמוד מצייר מוגבל של שדה גבהים בצד המעבד כאשר קיימים ממשקי גרפיקה מודרניים? הוא מספק מקרה ברור לבחינת פשרות, לא תחליף לכל מנוע GPU.
מפתחים מודרניים יכולים ללמוד שלושה לקחים חשובים מטכנולוגיית הרטרו הזאת:
1. מקומיות מטמון המעבד ויעילות זיכרון
המערכים קומפקטיים, אך הגישה אינה רציפה בכל מקום. בפריסת המקור, כתיבה אנכית למסך מתקדמת בקפיצות של width איברים, ודגימות המפה תלויות בכיוון הקרן. אלו גישות מדורגות או לא סדירות, לא הוכחה למקומיות מטמון אידיאלית. מדדו את הסריקה ואת פריסת הנתונים לפני טענה לשיפור.
2. כוחן של חלופות תוכנתיות
Canvas 2D עשוי להיות חלופה מעשית להדמיות מוגבלות מסוימות. מימושי דפדפן עדיין עשויים להשתמש בהאצה גרפית פנימית, והתמיכה או הביצועים אינם אוניברסליים. טפלו בהיעדר הקשר ובשגיאות משאבים; יצירת פיקסלים בצד המעבד אינה מבטיחה היעדר בעיות מנהלי התקנים, דליפות זיכרון או אובדן הקשר.
3. חשיבה אלגוריתמית במקום כוח גס
הגבלת הגאומטריה והתנהגות המצלמה יכולה לצמצם את העבודה הנדרשת לסצנה מסוימת. המחיר הוא אובדן יכולות ושגיאת קירוב. השוו איכות, זמן פריים וזיכרון במכשיר היעד במקום לטעון לשיפור של סדרי גודל.
סיכום: היופי בעשיית יותר בפחות
Voxel Space מראה שאלגנטיות בפיתוח תוכנה אינה שימוש במסגרת החדשה והמורכבת ביותר. היא הכרת האילוצים, הבנת הנתונים וכתיבת קוד נקי ומכוון שפותר בעיה יפה.
בעמוד בדיקה נפרד ומורשה, הכינו מפות מהימנות ותואמות בגודל 1024×1024 שמותר לכם להשתמש בהן, סדרו את שלושת מקטעי JavaScript לפי הסדר והגישו רק תיקיית הדגמה ייעודית דרך loopback. אל תחשפו תיקיות אישיות באמצעות שרת HTTP כללי. בדקו תמונות חסרות, מידות שגויות, מיקוד מקלדת, השהיה, חיתוך קדמה וקצבי רענון שונים לפני שחרור הדגמה. לא הופעל כאן שרת ולא בוצעה בדיקת דפדפן.
אילו פשרות בציור רטרו חקרתם? שתפו דוגמה קטנה שאינה רגישה ואת המדידות שבהן השתמשתם, במקום טענת ביצועים ללא בסיס.