ピカニーの3Dもどきレーシング解説

ピカニーの3Dもどきレーシング解説
まいど!ピカニーやで!
今日は、キミのブラウザで3Dもどきのレーシングゲームを動かすためのスーパーファミコンの技術を使ったマジックを紹介するで!
しかも、ジミニーコードエディターにコピペするだけで遊べるんや!🚗💨 ほな、いくで!
フル画面は こちらのリンク からアクセスできます。
🎮 これはスーパーファミコンの「モード7」を参考にして作ったんや!
昔、スーパーファミコンにはモード7っていうすごい技術があったんや!
この技術を使うと、平面の画像(2D)を拡大・縮小・回転させて、3Dみたいに見せることができたんや!
簡単に言うと、『ほんまは2Dの画像やけど、カメラの角度を変えて3D風に見せる』ってことやな!
今回のゲームも、それを参考にして作っとるで!
ただ、スーパーファミコンのモード7とはちょっと違って、画像を何層にも積み重ねることで、よりリアルな奥行きを表現してるんや!
🖼️ 画像を3Dプリンターみたいに積み重ねて、3Dっぽく見せてるんや!
このゲーム、ホンマは3Dモデルとか使ってへんのやで!
奥行きを出すために、画像(テクスチャ)を何層にも重ねて、遠近感を作っとるんや!
まるで3Dプリンターが1枚ずつレイヤーを重ねて立体を作るようなイメージや!
例えば、1枚の道路の画像をそのまま表示するとただの平面やけど、
それを奥に行くほど小さく、前に来るほど大きく表示すると、まるで立体に見えるんや!
これが「疑似3D」ってやつやな!
これを作るために、WebGL2のシェーダー技術を使って、遠近感や光の当たり具合を計算しとるんや!
…まぁ、簡単に言うと、スーパーファミコンの「モード7」をもっとパワーアップさせた感じやな!
🚦 まずは操作方法や!
このゲーム、実はキミが車を運転してるわけやないんや!カメラを動かして、まるで自分が車に乗ってるように見せるんや!
ワイの車のアクセルは ON / OFFスイッチ式 や!止まるか、フルスロットルかしかないで!(アカン)
🎮 操作一覧
- ✅ アクセル(前進): 上キー(↑) or 「アクセル」ボタン
- ✅ ブレーキ(減速): 下キー(↓) or 「ブレーキ」ボタン
- ✅ 左へハンドル(カメラ回転): 左キー(←) or 「左」ボタン
- ✅ 右へハンドル(カメラ回転): 右キー(→) or 「右」ボタン
ハンドル操作は、車の向きを変えてるんやなくて、カメラが曲がるんやで!
つまり、キミは車に乗ってるんやなくて、ドローン視点でレースしてるようなもんや!
スーパーファミコンのあのレースゲームと同じ仕組みやな!(わかる人にはわかる)
🎨 カスタマイズもできるで!
このレースゲーム、ただ走るだけやない!いろんなパラメータを調整して、
自分好みの走行感覚を作れるんや!まさにDIYレーシング!
🔧 いじれるパラメータ
- 🛠 nLevels:地面のレイヤーの数(これを増やすと、奥行き感アップ!)
- 🛠 slicesPerLevel:1レイヤーあたりの分割数(細かくすると、なめらかに!)
- 🛠 globalLevelHeight:地面の高さ(高くすると坂道っぽくなる!)
- 🛠 nearPitch & farPitch:地面の遠近感の調整(リアルな立体感が出る!)
- 🛠 uHeightScale:地形の起伏(デコボコの感じを変えられる!)
- 🛠 pPow:遠近感の強さ(数字を変えると、目の錯覚がバグるで!)
これをいじれば、自分だけのオリジナルコースを作れるんや!
むちゃくちゃデコボコの道を作ったり、坂道ばっかのジェットコースター風レースもできるで!
ワイのおすすめ設定:「nLevels 10」「pPow 2.0」…これ、地面が宇宙のように歪むで!(?)
📱 携帯で遊ぶときの注意!
携帯で遊ぶときは、「nLevels」の値を1か2くらいにしとくのがええで!
レイヤーを増やしすぎると、めっちゃ遅くなるからな!⚠️
『スマホが止まった!?』ってならんように、控えめに設定しとくんや!
🔥 目指せ最速!レースのコツ!
おいおい、ただ走るだけじゃつまらんやろ?
ここで、このゲームで最速を目指すためのコツを教えたる!
🏎️ 最速テクニック
- ✅ カーブのときは、アクセル全開&カメラをちょっと傾ける!
- ✅ ブレーキはなるべく使わない!アクセルオフで減速や!
- ✅ 視点を高くすると、先の道が見やすくなる!
- ✅ pPowの値をいじると、奥行きが変わってスピード感が変わる!
速く走りたいなら、ブレーキなんていらん!突っ込め!(…責任は取らんで?)
目指せ、ブラウザ最速レーサー! 🚗💨💨
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Layered Mode7 – WebGL2版</title>
<style>
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
}
canvas {
display: block;
touch-action: none;
}
/* PC向けのスタイル(デフォルト) */
#controls {
position: absolute;
top: 10px;
left: 10px;
background: rgba(255, 255, 255, 0.0);
padding: 10px;
border-radius: 5px;
font-family: sans-serif;
font-size: 16px;
z-index: 100;
}
#controls div {
margin-bottom: 5px;
}
#controls label {
display: inline-block;
min-width: 120px;
}
#controls input[type="range"] {
width: 200px;
height: 20px;
vertical-align: middle;
}
#buttons {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
}
#buttons button {
font-size: 16px;
padding: 10px 15px;
margin: 5px;
min-width: 100px;
border: none;
border-radius: 5px;
background: #666;
color: #fff;
cursor: pointer;
}
/* スマホ向け(画面幅600px以下)の場合、サイズを大きく */
@media (max-width: 600px) {
#controls {
font-size: 15px;
padding: 10px;
}
#controls label {
min-width: 100px;
}
#controls input[type="range"] {
width: 250px;
height: 5px;
}
#buttons button {
font-size: 20px;
padding: 15px 14px;
min-width: 50px;
}
}
</style>
</head>
<body>
<div id="controls">
<div>
<label>nLevels: <span id="nLevelsValue">2</span></label>
<input type="range" id="nLevelsSlider" min="1" max="50" value="2">
</div>
<div>
<label>slicesPerLevel: <span id="slicesValue">2</span></label>
<input type="range" id="slicesSlider" min="1" max="50" value="2">
</div>
<div>
<label>globalLevelHeight: <span id="levelHeightValue">14</span></label>
<input type="range" id="levelHeightSlider" min="10" max="100" value="14">
</div>
<div>
<label>nearPitch: <span id="nearPitchValue">4.6</span></label>
<input type="range" id="nearPitchSlider" min="0" max="10" step="0.1" value="4.6">
</div>
<div>
<label>farPitch: <span id="farPitchValue">1.3</span></label>
<input type="range" id="farPitchSlider" min="0" max="10" step="0.1" value="1.3">
</div>
<div>
<label>uHeightScale: <span id="heightScaleValue">1</span></label>
<input type="range" id="heightScaleSlider" min="0.1" max="10" step="0.1" value="9.3">
</div>
<div>
<label>pPow: <span id="pPowValue">1.0</span></label>
<input type="range" id="pPowSlider" min="0.5" max="2.0" step="0.1" value="1.3">
</div>
</div>
<!-- 操作用ボタン -->
<div id="buttons">
<button id="btnAccel">アクセル</button>
<button id="btnBrake">ブレーキ</button>
<button id="btnLeft">左</button>
<button id="btnRight">右</button>
</div>
<canvas id="glCanvas"></canvas>
<script>
(() => {
// --- WebGL2 初期化 ---
const canvas = document.getElementById("glCanvas");
const gl = canvas.getContext("webgl2");
if (!gl) { alert("WebGL2がサポートされていません"); return; }
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
// --- UI要素取得 ---
const nLevelsSlider = document.getElementById("nLevelsSlider");
const slicesSlider = document.getElementById("slicesSlider");
const levelHeightSlider = document.getElementById("levelHeightSlider");
const nearPitchSlider = document.getElementById("nearPitchSlider");
const farPitchSlider = document.getElementById("farPitchSlider");
const heightScaleSlider = document.getElementById("heightScaleSlider");
const pPowSlider = document.getElementById("pPowSlider");
const nLevelsValue = document.getElementById("nLevelsValue");
const slicesValue = document.getElementById("slicesValue");
const levelHeightValue = document.getElementById("levelHeightValue");
const nearPitchValue = document.getElementById("nearPitchValue");
const farPitchValue = document.getElementById("farPitchValue");
const heightScaleValue = document.getElementById("heightScaleValue");
const pPowValue = document.getElementById("pPowValue");
nLevelsSlider.addEventListener("input", () => { nLevelsValue.textContent = nLevelsSlider.value; });
slicesSlider.addEventListener("input", () => { slicesValue.textContent = slicesSlider.value; });
levelHeightSlider.addEventListener("input", () => { levelHeightValue.textContent = levelHeightSlider.value; });
nearPitchSlider.addEventListener("input", () => { nearPitchValue.textContent = nearPitchSlider.value; });
farPitchSlider.addEventListener("input", () => { farPitchValue.textContent = farPitchSlider.value; });
heightScaleSlider.addEventListener("input", () => { heightScaleValue.textContent = heightScaleSlider.value; });
pPowSlider.addEventListener("input", () => { pPowValue.textContent = pPowSlider.value; });
// --- 操作用変数 ---
let controls = { accelerate: false, brake: false, turnLeft: false, turnRight: false };
// --- キーボード操作 ---
document.addEventListener("keydown", e => {
if (e.key === "ArrowUp") controls.accelerate = true;
if (e.key === "ArrowDown") controls.brake = true;
if (e.key === "ArrowLeft") controls.turnLeft = true;
if (e.key === "ArrowRight") controls.turnRight = true;
});
document.addEventListener("keyup", e => {
if (e.key === "ArrowUp") controls.accelerate = false;
if (e.key === "ArrowDown") controls.brake = false;
if (e.key === "ArrowLeft") controls.turnLeft = false;
if (e.key === "ArrowRight") controls.turnRight = false;
});
// --- ボタン操作 ---
const btnLeft = document.getElementById("btnLeft");
const btnAccel = document.getElementById("btnAccel");
const btnBrake = document.getElementById("btnBrake");
const btnRight = document.getElementById("btnRight");
function addButtonListeners(btn, onFunc, offFunc) {
btn.addEventListener("mousedown", e => { onFunc(); });
btn.addEventListener("mouseup", e => { offFunc(); });
btn.addEventListener("touchstart", e => { e.preventDefault(); onFunc(); });
btn.addEventListener("touchend", e => { e.preventDefault(); offFunc(); });
}
addButtonListeners(btnLeft, () => { controls.turnLeft = true; }, () => { controls.turnLeft = false; });
addButtonListeners(btnRight, () => { controls.turnRight = true; }, () => { controls.turnRight = false; });
addButtonListeners(btnAccel, () => { controls.accelerate = true; }, () => { controls.accelerate = false; });
addButtonListeners(btnBrake, () => { controls.brake = true; }, () => { controls.brake = false; });
// --- シェーダー定義 ---
// WebGL2 では GLSL ES 3.00 を利用
const vsSource = `#version 300 es
in vec2 aPosition;
in float aLayer;
in float aLayerOffset;
out vec2 vTexCoord;
out float vLayer;
void main() {
vTexCoord = (aPosition + 1.0) * 0.5;
vLayer = aLayer;
gl_Position = vec4(aPosition.x, aPosition.y + aLayerOffset, 0.0, 1.0);
}
`;
const fsSource = `#version 300 es
precision mediump float;
in vec2 vTexCoord;
in float vLayer;
out vec4 fragColor;
uniform vec2 uResolution;
uniform float uHorizon;
uniform float uCamX;
uniform float uCamY;
uniform float uCamAngle;
uniform float uCamHeight;
uniform float uFov;
uniform sampler2D uBaseTex;
uniform sampler2D uHeightTex;
uniform vec2 uTexSize;
uniform float uHeightScale;
uniform float uPPow;
void main() {
vec2 screenPos = vTexCoord * uResolution;
if(screenPos.y >= uHorizon) {
fragColor = vec4(0.53, 0.81, 0.92, 1.0);
return;
}
float p = (uHorizon - screenPos.y) / uHorizon;
float pCorr = pow(p, uPPow);
float minP = 0.05;
pCorr = max(pCorr, minP);
float distanceNoHeight = uCamHeight / pCorr;
float uNorm = (screenPos.x - (uResolution.x * 0.5)) / (uResolution.x * 0.5);
uNorm = -uNorm;
float halfViewWidth = distanceNoHeight * tan(uFov / 2.0);
vec2 center = vec2(uCamX, uCamY) + distanceNoHeight * vec2(cos(uCamAngle), sin(uCamAngle));
vec2 offset = vec2(-sin(uCamAngle), cos(uCamAngle)) * (uNorm * halfViewWidth);
vec2 worldPos = center - offset;
vec2 uvBase = clamp(worldPos / uTexSize, 0.0, 1.0);
float hVal = texture(uHeightTex, uvBase).r;
float terrainHeight = hVal * uHeightScale;
if(terrainHeight < vLayer) { discard; }
float finalDistance = (uCamHeight - terrainHeight) / pCorr;
vec2 center2 = vec2(uCamX, uCamY) + finalDistance * vec2(cos(uCamAngle), sin(uCamAngle));
float halfViewWidth2 = finalDistance * tan(uFov / 2.0);
vec2 offset2 = vec2(-sin(uCamAngle), cos(uCamAngle)) * (uNorm * halfViewWidth2);
vec2 worldPos2 = center2 - offset2;
vec2 uv = clamp(worldPos2 / uTexSize, 0.0, 1.0);
vec4 color = texture(uBaseTex, uv);
fragColor = vec4(color.rgb, 1.0);
}
`;
// --- シェーダーコンパイル・リンク ---
function compileShader(src, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
const vs = compileShader(vsSource, gl.VERTEX_SHADER);
const fs = compileShader(fsSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Program linking error:", gl.getProgramInfoLog(program));
}
gl.useProgram(program);
// --- VAO 作成 ---
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// --- 頂点バッファ設定 ---
const vertices = new Float32Array([
-1, -1,
1, -1,
-1, 1,
1, 1,
]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const aPositionLoc = gl.getAttribLocation(program, "aPosition");
gl.enableVertexAttribArray(aPositionLoc);
gl.vertexAttribPointer(aPositionLoc, 2, gl.FLOAT, false, 0, 0);
// --- インスタンス属性用バッファ作成 ---
let nLevelsVal = parseInt(nLevelsSlider.value);
let slicesPerLevelVal = parseInt(slicesSlider.value);
let totalInstances = nLevelsVal * slicesPerLevelVal;
let instanceData = new Float32Array(totalInstances * 2); // [aLayer, aLayerOffset] ペア
const instanceBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
gl.bufferData(gl.ARRAY_BUFFER, instanceData, gl.DYNAMIC_DRAW);
const aLayerLoc = gl.getAttribLocation(program, "aLayer");
gl.enableVertexAttribArray(aLayerLoc);
gl.vertexAttribPointer(aLayerLoc, 1, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribDivisor(aLayerLoc, 1);
const aLayerOffsetLoc = gl.getAttribLocation(program, "aLayerOffset");
gl.enableVertexAttribArray(aLayerOffsetLoc);
gl.vertexAttribPointer(aLayerOffsetLoc, 1, gl.FLOAT, false, 2 * 4, 4);
gl.vertexAttribDivisor(aLayerOffsetLoc, 1);
gl.bindVertexArray(null);
// --- uniform ロケーション取得 ---
const uResolutionLoc = gl.getUniformLocation(program, "uResolution");
const uHorizonLoc = gl.getUniformLocation(program, "uHorizon");
const uCamXLoc = gl.getUniformLocation(program, "uCamX");
const uCamYLoc = gl.getUniformLocation(program, "uCamY");
const uCamAngleLoc = gl.getUniformLocation(program, "uCamAngle");
const uCamHeightLoc = gl.getUniformLocation(program, "uCamHeight");
const uFovLoc = gl.getUniformLocation(program, "uFov");
const uTexSizeLoc = gl.getUniformLocation(program, "uTexSize");
const uHeightScaleLoc = gl.getUniformLocation(program, "uHeightScale");
const uPPowLoc = gl.getUniformLocation(program, "uPPow");
gl.uniform2f(uResolutionLoc, canvas.width, canvas.height);
gl.uniform1f(uHorizonLoc, canvas.height * 0.66);
gl.uniform1f(uCamHeightLoc, 100.0);
gl.uniform1f(uFovLoc, Math.PI / 3);
gl.uniform2f(uTexSizeLoc, 512.0, 512.0);
// --- カメラ・操作変数 ---
let camX = 100.0, camY = 100.0, camAngle = 0.0;
let moveSpeed = 0.0;
const accel = 0.01, fric = 0.01, turnSpd = 0.01;
function updateControls() {
if (controls.accelerate) moveSpeed += accel;
if (controls.brake) moveSpeed -= accel;
if (!controls.accelerate && !controls.brake) {
if (moveSpeed > 0) { moveSpeed -= fric; if (moveSpeed < 0) moveSpeed = 0; }
else if (moveSpeed < 0) { moveSpeed += fric; if (moveSpeed > 0) moveSpeed = 0; }
}
if (controls.turnLeft) camAngle -= turnSpd;
if (controls.turnRight) camAngle += turnSpd;
camX += Math.cos(camAngle) * moveSpeed;
camY += Math.sin(camAngle) * moveSpeed;
}
// --- テクスチャ読み込み ---
function loadTexture(url, textureUnit, callback) {
const tex = gl.createTexture();
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function () {
gl.activeTexture(gl.TEXTURE0 + textureUnit);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
callback(img.width, img.height);
};
img.src = url;
return tex;
}
let texturesLoaded = 0;
function onTextureLoaded() {
texturesLoaded++;
if (texturesLoaded === 2) requestAnimationFrame(renderLoop);
}
// 低解像度テクスチャまたは事前縮小済み画像を利用
const baseTexURL = "https://doiworks.com/wp-content/uploads/2025/03/race_track_original_s.png";
const heightTexURL = "https://doiworks.com/wp-content/uploads/2025/03/height_map_grayscale_s.png";
const baseTexture = loadTexture(baseTexURL, 0, (w, h) => {
gl.uniform2f(uTexSizeLoc, w, h);
onTextureLoaded();
});
gl.uniform1i(gl.getUniformLocation(program, "uBaseTex"), 0);
const heightTexture = loadTexture(heightTexURL, 1, (w, h) => { onTextureLoaded(); });
gl.uniform1i(gl.getUniformLocation(program, "uHeightTex"), 1);
// --- レンダーループ ---
function renderLoop() {
updateControls();
gl.useProgram(program);
gl.uniform1f(uCamXLoc, camX);
gl.uniform1f(uCamYLoc, camY);
gl.uniform1f(uCamAngleLoc, camAngle);
gl.uniform2f(uResolutionLoc, canvas.width, canvas.height);
gl.uniform1f(uHorizonLoc, canvas.height * 0.66);
// --- スライダーからパラメータ取得 ---
const nLevels = parseInt(nLevelsSlider.value);
const slicesPerLevel = parseInt(slicesSlider.value);
const globalLevelHeight = parseFloat(levelHeightSlider.value);
const nearPitch = parseFloat(nearPitchSlider.value);
const farPitch = parseFloat(farPitchSlider.value);
const heightScale = parseFloat(heightScaleSlider.value);
const pPow = parseFloat(pPowSlider.value);
gl.uniform1f(uHeightScaleLoc, heightScale);
gl.uniform1f(uPPowLoc, pPow);
// インスタンスデータ更新
totalInstances = nLevels * slicesPerLevel;
if (instanceData.length !== totalInstances * 2) {
instanceData = new Float32Array(totalInstances * 2);
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
gl.bufferData(gl.ARRAY_BUFFER, instanceData, gl.DYNAMIC_DRAW);
}
for (let level = 0; level < nLevels; level++) {
for (let slice = 0; slice < slicesPerLevel; slice++) {
const globalIndex = level * slicesPerLevel + slice;
const currentLayer = level * (globalLevelHeight / nLevels)
+ slice * ((globalLevelHeight / nLevels) / slicesPerLevel);
const ratio = globalIndex / (totalInstances - 1);
const currentPitch = nearPitch * (1.0 - ratio) + farPitch * ratio;
const offsetNormalized = globalIndex * (currentPitch * 2 / canvas.height);
instanceData[globalIndex * 2] = currentLayer;
instanceData[globalIndex * 2 + 1] = offsetNormalized;
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceData);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, totalInstances);
requestAnimationFrame(renderLoop);
}
requestAnimationFrame(renderLoop);
})();
</script>
</body>
</html>
ジミニー Code Editor
下記のエディタ内で、上記のコードの実行や編集が可能です。ぜひお試しください!
※今回のこの画面でコードを実行するときはモードを3、か4にして実行してください
ジミニー Code Editor は こちらのリンク からアクセスできます。
コメント ( 0 )
トラックバックは利用できません。
この記事へのコメントはありません。