Mode7風 疑似3Dレーシングの実装例その2【タッチ&マウス対応】

Mode7風 疑似3Dレーシングの実装例【タッチ&マウス対応】その2
Mode7 風の疑似3Dレーシングシステムの実装例です。以下の解説では、コード全体の構成と各部分がどのような役割を担い、どのような手法でシステムを構成しているのかを詳しく説明します。
フル画面は こちらのリンク からアクセスできます。
1. HTML と CSS の構成
HTML 部分
・UI 部品の配置
• #controls
:複数の <input type="range">
を用いて、各パラメータ(レイヤ数、スライス数、レベルの高さ、近距離と遠距離のピッチ、テクスチャの高さスケール、pPow)の値を動的に調整できるようにしています。
• #buttons
:「左」「アクセル」「ブレーキ」「右」などのボタンで、マウスやタッチ操作による車両の制御(旋回・加減速)を実現します。
• canvas#glCanvas
:WebGL2 を用いた描画結果が表示されるキャンバスです。
CSS 部分
・基本レイアウトとレスポンシブ対応:画面全体をキャンバスが占有するように、margin
や padding
をリセットし、overflow: hidden;
によりスクロールバーを非表示にしています。
・PC 向けとスマホ向けのスタイルを分け、スマートフォンの場合はフォントサイズやスライダー・ボタンのサイズを大きくするなど、使いやすさを意識したデザインになっています。
2. JavaScript 部分
2.1 WebGL2 の初期化とキャンバスのリサイズ
- WebGL2 コンテキストの取得:
const gl = canvas.getContext("webgl2");
により、最新の WebGL2 を利用して高速なレンダリングを実現。対応していないブラウザではアラートを出し、処理を中断します。 - キャンバスサイズの自動調整:
resizeCanvas
関数を用いて、ウィンドウサイズに応じてキャンバスのサイズと WebGL のビューポートを更新。これにより、どの画面サイズでも正しく描画されます。
2.2 ユーザーインターフェースと操作
- スライダーによるパラメータ変更: 各スライダーの
input
イベントで表示テキストも更新し、リアルタイムに描画パラメータ(レイヤ数、スライス数、各種ピッチ、スケール値など)を調整できるようになっています。 - キーボードとボタン操作: キーボードの矢印キーにより、加速、ブレーキ、左右旋回の操作が行われ、画面上のボタンもマウス・タッチイベントに対応して同様の制御が可能です。
2.3 シェーダーの定義とコンパイル
- Vertex Shader: 入力属性として、頂点座標 (
aPosition
) とインスタンスごとの属性 (aLayer
,aLayerOffset
) を受け取り、aPosition
を [-1, 1] から [0, 1] のテクスチャ座標に正規化し、aLayerOffset
を y 座標に加えることで各レイヤーの位置を調整します。 - Fragment Shader: 各ピクセルに対し、カメラの位置、視野角、テクスチャサイズ、高さ情報 (
uHeightScale
) を用いて疑似的な遠近感と奥行きを計算。画面上部(空)は一定の青空色、下部(地面)は透視投影計算とテクスチャサンプリングにより Mode7 効果を再現し、高さマップ (uHeightTex
) を利用して各レイヤーの描画を制御します(条件に合わないピクセルはdiscard
されます)。
2.4 バッファとインスタンシングによる描画
- 頂点バッファとインスタンス属性: 単純な四角形(2Dクアッド)を描画するための頂点バッファと、各レイヤー(およびスライス)ごとの
aLayer
とaLayerOffset
のインスタンス属性バッファを作成。これにより、1 回の描画呼び出しで複数のレイヤーを効率的に描画し、CPU と GPU 間の呼び出し回数を削減します。 - インスタンスデータの更新: 毎フレーム、スライダーの値に基づき各レイヤーの値やオフセットを再計算し、バッファにアップロード。ユーザー操作によりシーン全体のレイヤー構成がリアルタイムに変化します。
2.5 テクスチャの読み込み
- 非同期でのテクスチャロード:
loadTexture
関数により、外部 URL から基本テクスチャ (uBaseTex
) と高さマップ (uHeightTex
) を読み込み、両テクスチャがロード完了した後にレンダーループを開始します。
2.6 レンダーループとカメラ制御
- カメラの移動と操作: カメラの位置 (
camX
,camY
) と角度 (camAngle
) は、ユーザー操作(アクセル、ブレーキ、左右旋回)に基づいて更新され、加速、減速、摩擦(fric
)等のパラメータで自然な動きを実現しています。 - 毎フレームの描画更新:
requestAnimationFrame
を用いたレンダーループで、カメラパラメータの更新、インスタンスデータの再計算、各種 uniform の更新、そして最終的な描画(gl.drawArraysInstanced
)を行い、ユーザーの操作に合わせた動的なシーン再構成とリアルタイムな Mode7 効果を実現しています。
3. システム全体の概要
このコードは、WebGL2 と GLSL ES 3.00 のシェーダープログラミングを活用した Mode7 風の疑似3Dレーシングシステムです。
インスタンシング: 1 回の描画呼び出しで複数のレイヤー(およびスライス)を描画することで、パフォーマンスを向上させ、各レイヤーに異なるオフセット値を与えることで疑似的な立体感と遠近法を再現します。
シェーダーによる透視変換とテクスチャサンプリング: ピクセルごとにカメラ位置、視野角、テクスチャの高さマップ情報を利用し、遠近感のある地形表現を実現。特に Fragment Shader 内でテクスチャ座標の補正や高さ判定を行い、各レイヤーの描画可否を制御しています。
ユーザーインターフェースとの連動: UI スライダーや操作ボタン、キーボードイベントでパラメータを調整でき、シーンの見た目や操作感を動的に変更可能です。
非同期テクスチャ読み込みとリサイズ対応: 画像のロード完了後に描画を開始し、ウィンドウサイズの変化にも自動対応することで、どの環境でも一貫した動作を保証します。
最終版ソースコード
下記のコードをコピーして、自由にお使いください。各パラメータはリアルタイムに変更可能なため、動作や見た目のカスタマイズも容易です。
<!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 )
トラックバックは利用できません。
この記事へのコメントはありません。