曲線・曲面の数式表現
さまざまな形状モデル では、立体を稜線・面・中身のどれで捉えるかによって形状モデルを分類しました。ここからは、その面や線にあたる曲線・曲面の数式表現を見ていきます。
陽関数形式
陽関数形式は、一方の座標を、もう一方の座標の関数として与える形式です。
平面上の曲線であれば、次のように書けます。
を決めれば が計算だけで求められ、その座標 の点を並べることで曲線を描きます。グラフを描くときにも使い慣れた形です。
曲面の場合は、高さにあたる座標を、残る2つの座標の関数として与えます。
この形は、平面上のどの点にも高さが1つずつ割り当てられたものと読めます。地形の起伏はこの形で表せるため、こうした曲面はハイトフィールド(高さ場)と呼ばれることがあります。
陽関数形式で表せる対象
ただし、陽関数形式で表せる形は限られます。
を決めたときに が1つに定まる形しか書けないため、たとえば円のような、1つの に2つの が対応する曲線は表せません。
曲面でも同じ問題が起こります。真横を向いた壁や、下へせり出した形は、平面上の同じ位置に複数の高さを必要とするため、ハイトフィールドの枠には収まりません。
xとyを動かして、xの位置を表す縦線が円・曲面と交わる点を数えてみよう
- 円と交わる点は、同じ
xの位置に2つ現れることがある - 曲面と交わる点は、同じ
xの位置に3つ現れることがある
Three.jsによる実装概要
// 左に円、右にせり出した曲面を並べる
const curvePanel = new Group()
curvePanel.position.x = -GROUP_OFFSET
const surfacePanel = new Group()
surfacePanel.position.set(GROUP_OFFSET, -GROUND_DROP, 0)
scene.add(curvePanel, surfacePanel)
// 円 x² + y² − r² = 0。xy 平面を立てて置き、x が右・y が上のまま読めるようにする
const circlePoints: Vector3[] = []
for (let i = 0; i < CIRCLE_SEGMENTS; i++) {
const angle = (i / CIRCLE_SEGMENTS) * Math.PI * 2
circlePoints.push(
new Vector3(CIRCLE_RADIUS * Math.cos(angle), CIRCLE_RADIUS * Math.sin(angle), 0)
)
}
const circleGeometry = new BufferGeometry().setFromPoints(circlePoints)
const circleMaterial = new LineBasicMaterial({ color: CURVE_COLOR })
curvePanel.add(new LineLoop(circleGeometry, circleMaterial))
// せり出した曲面の輪郭。(z 軸からの距離, 高さ) を下から上へ並べる。
// 胴がふくらんでから首でくびれ、口が外へ開くので、口の下では高さが 3 つになる
const PROFILE: [number, number][] = [
[0.0, 0.0],
[0.3, 0.03],
[0.48, 0.16],
[0.58, 0.36],
[0.62, 0.58],
[0.58, 0.78],
[0.47, 0.96],
[0.37, 1.14],
[0.33, 1.3],
[0.36, 1.42],
[0.46, 1.54],
[0.6, 1.63]
]
// 輪郭を回してできる曲面。LatheGeometry は上向きの軸のまわりに回すので、高さがそのまま高さになる。
// せり出しの下を通る縦線と交点が見えるよう、半透明にして深度は書かない
const surfaceGeometry = new LatheGeometry(
PROFILE.map(([radius, height]) => new Vector2(radius, height)),
LATHE_SEGMENTS
)
const surfaceMaterial = new MeshStandardMaterial({
color: SURFACE_COLOR,
roughness: 0.6,
side: DoubleSide,
transparent: true,
opacity: SURFACE_OPACITY,
depthWrite: false
})
surfacePanel.add(new Mesh(surfaceGeometry, surfaceMaterial))
// 縦線 x = 一定 が円と交わる y。半径の外では交点がない
const findCircleHeights = (x: number) => {
const squared = CIRCLE_RADIUS * CIRCLE_RADIUS - x * x
if (squared < 0) return []
const y = Math.sqrt(squared)
// 円の左端・右端では上下の交点が重なって 1 つになる
return y === 0 ? [0] : [-y, y]
}
// z 軸から distance だけ離れたところで、輪郭が持つ高さをすべて求める
const findSurfaceHeights = (distance: number) => {
const heights: number[] = []
for (let i = 0; i < PROFILE.length - 1; i++) {
const [r0, h0] = PROFILE[i]
const [r1, h1] = PROFILE[i + 1]
// この区間をまたいでいなければ、輪郭はこの距離を通らない
if (r0 === r1 || (distance - r0) * (distance - r1) > 0) continue
heights.push(h0 + ((distance - r0) / (r1 - r0)) * (h1 - h0))
}
// 区間の継ぎ目をちょうど通ったときは同じ高さが 2 度出るので、隣り合う重なりを落とす
return heights.filter(
(height, index) => index === 0 || Math.abs(height - heights[index - 1]) > 1e-4
)
}
// 縦線を立てる位置。円は x だけで決まり、曲面は xy 平面上の (x, y) で決まる
const x = 0.4
const y = 0.2
const probeMaterial = new LineBasicMaterial({ color: PROBE_COLOR })
// 円の図の縦線。y 軸と平行なまま、x 軸に沿って動く
const curveProbe = new LineSegments(
new BufferGeometry().setFromPoints([
new Vector3(0, -CURVE_PROBE_HALF, 0),
new Vector3(0, CURVE_PROBE_HALF, 0)
]),
probeMaterial
)
curveProbe.position.x = x
curvePanel.add(curveProbe)
// 曲面の図の縦線。xy 平面上の (x, y) から真上に立てる。ワールドの奥行きが xy 平面の y にあたる
const surfaceProbe = new LineSegments(
new BufferGeometry().setFromPoints([
new Vector3(0, SURFACE_PROBE_BOTTOM, 0),
new Vector3(0, SURFACE_PROBE_TOP, 0)
]),
probeMaterial
)
surfaceProbe.position.set(x, 0, y)
surfacePanel.add(surfaceProbe)
// 縦線とぶつかった点。曲面は回転体なので、交わり方は z 軸からの距離だけで決まる
const hitGeometry = new SphereGeometry(HIT_RADIUS, 16, 12)
const hitMaterial = new MeshBasicMaterial({ color: HIT_COLOR })
for (const height of findCircleHeights(x)) {
const hit = new Mesh(hitGeometry, hitMaterial)
hit.position.set(x, height, 0)
curvePanel.add(hit)
}
for (const height of findSurfaceHeights(Math.hypot(x, y))) {
const hit = new Mesh(hitGeometry, hitMaterial)
hit.position.set(x, height, y)
surfacePanel.add(hit)
}
// 曲面のせり出しを陰影でも読み取れるようにする光。向きは固定
const light = new DirectionalLight(LIGHT_COLOR, 2.5)
light.position.set(4, 5, 3)
scene.add(light, new AmbientLight(LIGHT_COLOR, 0.5)) 陰関数形式
陽関数形式では表せない円などの図形は、別な数式の形で表す必要があります。
から を計算するという考え方を手放し、陰関数形式では、座標が満たすべき条件を方程式として与え、それを満たす点の集まりとして曲線・曲面を表現することができます。
平面上であれば、次の式を満たす点が曲線になり、この形の数式で表された曲線を陰関数曲線といいます。
曲面の場合は変数が1つ増え、次の式を満たす点の集まりが陰関数曲面です。
原点を中心とする半径 の円は、陰関数形式では次のように書けます。
円は「原点からの距離が である点の集まり」なので、その条件をそのまま式にすれば陰関数形式が得られます。点 と原点との距離は三平方の定理で求められるので、円周上の点が満たす条件は次のように書けます。
両辺を2乗して右辺を移項すると、上に挙げた円の式になります。
方程式の解は複数存在することもあります。そのため、陰関数形式では1つの に複数の が対応していても破綻しません。
陰関数形式と内外判定
球のように閉じた形状を陰関数形式で表すと、調べたい点の座標を に代入して符号を見るだけで、その点が内側にあるか外側にあるかを調べることができます。
陰関数形式の は、元々曲線の上の点が満たすべき条件として置いた式です。そのため、 の値は曲線・曲面の上でちょうど0になり、そこから外れた点では正か負のどちらかになります。
たとえば、円でいえば は次のような関数です。
この は、原点からの距離の2乗と、半径の2乗との差になっています。円周上の点は距離がちょうど なので差は0になり、円の内側の点は距離が より短いことから負に、外側の点は正になります。
陰関数形式と交点計算
交点を求めることは、2つの形状の式を同時に満たす点を探すことにあたります。陰関数形式では形状が方程式として与えられているので、それらを連立させて解けば交点の座標が得られます。
原点を中心とする半径 の円と、直線 の交点を求めてみます。円の式の に直線の式を代入すると、次のように だけの方程式になります。
展開すれば についての2次方程式なので、解の公式で解けます。解が2つあれば2点で交わり、重解なら接し、解がなければ交わりません。交点の座標も、そもそも交わるかどうかも、この方程式を解くだけでわかります。
曲面でも同じで、視線を表す式と曲面の式を連立させれば、その視線が形状のどこに当たるかが求まります。視線を追って画像を作るレイトレーシング法 では、このような交点計算が繰り返し行われます。
代数曲線と代数曲面
陰関数形式の にどのような関数を使うかは自由ですが、CGで扱う形状の多くは が多項式で書けるものです。多項式で表される陰関数曲線を代数曲線、同じく多項式で表される陰関数曲面を代数曲面といいます。
代数曲面を表す多項式の次数を曲面の次数といい、曲線についても同じように次数が決まります。次数が低いほど形は単純で、高くなるほど複雑な形を表せます。
多項式では、各項でかけ合わされている変数の個数を項の次数といい、その中で最も大きいものを多項式の次数といいます。
先ほどの円の式であれば、 の2乗と の2乗がどちらも2次の項で、半径だけの項は0次です。一番大きい次数が2なので、この多項式の次数は2になります。
2次の代数曲線・曲面
次数が2の代数曲線は2次曲線 と呼ばれ、円・楕円・放物線・双曲線は2次曲線で表されます。これらはいずれも円錐を平面で切った断面として現れるため、円錐曲線とも呼ばれます。
同じように、次数が2の代数曲面が2次曲面です。2次曲面では、球・楕円面・円柱・円錐などを表すことができます。
パラメータ形式
陰関数形式は、与えられた点が形状の上にあるかを判定する用途には向いていますが、曲線をラスタ化 してレンダリングするには負荷が高い方法です。
点の座標を得るには を について解かなければならず、 の形が複雑になるほど、この計算は高負荷なものになります。
そこで、座標そのものを補助的な別の変数から計算できる、パラメータ形式がCGではよく使われます。
この補助的な変数はパラメータまたは媒介変数と呼ばれます。
パラメータを とすると、2次元平面上の曲線の座標は次のように表されます。
3次元空間内の曲線を表したいときは、さらに を引数として 座標を表す関数を用意すればよいだけです。
このようにパラメータを使って表された曲線をパラメトリック曲線といいます。
例:平面上の直線のパラメータ表示
直線は、通る点が1つと、進む向きが決まれば定まります。点 を通り、 方向に ・ 方向に 進む向きの直線は、パラメータ を使って次のように書けます。
が0のときの点が で、 を増やすと特定の向きへ、 を減らすとその反対の向きへ進みます。 を1つ決めるごとに直線上の点が1つ得られ、 をすべての実数にわたって動かせば直線全体が得られます。
この形では と が対等に扱われ、どちらかをもう一方の関数として書く必要がありません。そのため、陽関数形式では表せなかった 軸に平行な直線も、 を0にするだけで表せます。
tを動かして、点(x, y)が直線上を動く様子を観察しよう
Three.jsによる実装概要
// 直線を決める、通る点 (p, q) と進む向き (a, b)。t は直線上の点を 1 つ選ぶパラメータ
const p = -1.2
const q = -0.6
const a = 1
const b = 0.8
const t = 1.4
// パラメータ表示 x = p + at, y = q + bt。
// xy 平面に重なる要素の前後を決める z も受け取る(直線全体と掃いた範囲は同じ直線上に重なるため)
const pointAt = (t: number, z: number) => new Vector3(p + a * t, q + b * t, z)
// t をすべての実数にわたって動かすと直線全体が得られる。
// 基準点から、向きの正負どちらへも表示域の外まで伸ばす
const direction = new Vector3(a, b, 0).normalize()
const guideGeometry = new BufferGeometry().setFromPoints([
pointAt(0, LAYER_LINE).addScaledVector(direction, -LINE_REACH),
pointAt(0, LAYER_LINE).addScaledVector(direction, LINE_REACH)
])
const guideMaterial = new LineBasicMaterial({ color: LINE_COLOR })
scene.add(new LineSegments(guideGeometry, guideMaterial))
// t = 0 から今の t までに掃いた範囲。t の正負で伸びる向きが変わる
const traceGeometry = new BufferGeometry().setFromPoints([
pointAt(0, LAYER_TRACE),
pointAt(t, LAYER_TRACE)
])
const traceMaterial = new LineBasicMaterial({ color: TRACE_COLOR })
scene.add(new LineSegments(traceGeometry, traceMaterial))
// t を 1 つ決めるごとに直線上の点が 1 つ決まる。整数の t に対応する点を並べる
const tickGeometry = new SphereGeometry(TICK_RADIUS, 12, 8)
const tickMaterial = new MeshBasicMaterial({ color: TICK_COLOR })
for (let tick = -TICK_MAX; tick <= TICK_MAX; tick++) {
// t = 0 の点は基準点として別に描くので飛ばす
if (tick === 0) continue
const dot = new Mesh(tickGeometry, tickMaterial)
dot.position.copy(pointAt(tick, LAYER_POINT))
scene.add(dot)
}
// 直線が通る 1 点として与えた (p, q) と、今の t に対応する点 (x, y)
const pointGeometry = new SphereGeometry(POINT_RADIUS, 16, 12)
const basePoint = new Mesh(pointGeometry, new MeshBasicMaterial({ color: BASE_COLOR }))
basePoint.position.copy(pointAt(0, LAYER_POINT))
const marker = new Mesh(pointGeometry, new MeshBasicMaterial({ color: MARKER_COLOR }))
marker.position.copy(pointAt(t, LAYER_POINT))
scene.add(basePoint, marker)例:平面上の円のパラメータ表示
平面上の半径 の円であれば、角度 をパラメータとして、次のように書くことができます。
を0から まで動かすと、円周上の点が順に得られます。方程式を解かずに、代入だけで点列を求められることが、陰関数形式との大きな違いです。
また、パラメータの動く範囲を制限することで、曲線の一部だけを表すこともできます。
θを動かして、点(x, y)の集まりが円周を描いていく様子を観察しよう
Three.jsによる実装概要
// 円を決める半径と、円周上の点を 1 つ選ぶパラメータ(操作しやすさのため度で持つ)
const r = 2
const thetaDeg = 225
const theta = (thetaDeg * Math.PI) / 180
// パラメータ表示 x = r cosθ, y = r sinθ。
// xy 平面に重なる要素の前後を決める z も受け取る(円周と掃いた円弧は同じ曲線上に重なるため)
const pointAt = (angle: number, z: number) =>
new Vector3(r * Math.cos(angle), r * Math.sin(angle), z)
// θ を 0 から 2π まで動かすと、円周がひと巡りぶん得られる
const circlePoints: Vector3[] = []
for (let i = 0; i < CIRCLE_SEGMENTS; i++) {
circlePoints.push(pointAt((i / CIRCLE_SEGMENTS) * Math.PI * 2, LAYER_LINE))
}
const circleGeometry = new BufferGeometry().setFromPoints(circlePoints)
const circleMaterial = new LineBasicMaterial({ color: LINE_COLOR })
scene.add(new LineLoop(circleGeometry, circleMaterial))
// θ = 0 から今の θ までに描かれた円弧
const arcPoints: Vector3[] = []
for (let i = 0; i <= ARC_SEGMENTS; i++) {
arcPoints.push(pointAt(theta * (i / ARC_SEGMENTS), LAYER_TRACE))
}
const arcGeometry = new BufferGeometry().setFromPoints(arcPoints)
const arcMaterial = new LineBasicMaterial({ color: TRACE_COLOR })
scene.add(new Line(arcGeometry, arcMaterial))
// θ を一定の刻みで動かして得られる点。今の θ までに到達したぶんだけ並べる
const dotGeometry = new SphereGeometry(DOT_RADIUS, 12, 8)
const dotMaterial = new MeshBasicMaterial({ color: DOT_COLOR })
for (let deg = 0; deg <= thetaDeg; deg += DOT_STEP_DEG) {
const dot = new Mesh(dotGeometry, dotMaterial)
dot.position.copy(pointAt((deg * Math.PI) / 180, LAYER_POINT))
scene.add(dot)
}
// 今の θ に対応する点
const markerGeometry = new SphereGeometry(POINT_RADIUS, 16, 12)
const markerMaterial = new MeshBasicMaterial({ color: MARKER_COLOR })
const marker = new Mesh(markerGeometry, markerMaterial)
marker.position.copy(pointAt(theta, LAYER_POINT))
scene.add(marker)
// 原点から今の点へ引いた半径
const radiusGeometry = new BufferGeometry().setFromPoints([
new Vector3(0, 0, LAYER_RADIUS),
pointAt(theta, LAYER_RADIUS)
])
const radiusMaterial = new LineBasicMaterial({ color: RADIUS_COLOR })
scene.add(new LineSegments(radiusGeometry, radiusMaterial))曲面のパラメータ表示
曲面を表す場合は、パラメータを2つ使います。
こうして表される曲面がパラメトリック曲面です。曲面は縦横2方向に広がりを持つため、その広がりに合わせてパラメータも2つ要ります。 と の組を決めると空間中の点が1つ決まり、 と を平面上の領域内で動かした点の集まりが、空間中の曲面として写されます。
uとvを動かして、uv平面上の1点が曲面上のどこの1点に写るかを追ってみよう
Three.jsによる実装概要
// 記事と同じ座標の書き方(z が高さ)をそのまま使えるよう、グループごと寝かせて z 軸を上に向ける
const group = new Group()
group.rotation.x = -Math.PI / 2
scene.add(group)
// (u, v) の組から、空間中の点を求める。
// u は軸のまわりを回る角度に、v は上下へ振れる角度にあてて、球面の一部を切り取った曲面にする
const evaluate = (u: number, v: number, target: Vector3) => {
const around = u * U_SWEEP
const upward = (v - 0.5) * V_SWEEP
const ring = RADIUS * Math.cos(upward)
return target.set(ring * Math.cos(around), ring * Math.sin(around), RADIUS * Math.sin(upward))
}
// (u, v) の格子を空間へ写して、曲面の頂点を並べる
const positions: number[] = []
const indices: number[] = []
const point = new Vector3()
for (let i = 0; i <= SURFACE_STEPS_U; i++) {
for (let j = 0; j <= SURFACE_STEPS_V; j++) {
evaluate(i / SURFACE_STEPS_U, j / SURFACE_STEPS_V, point)
positions.push(point.x, point.y, point.z)
}
}
// 格子の 1 マスを三角形 2 枚に割る。u 方向に 1 つ進むと、頂点の番号は v の分割数だけ飛ぶ
for (let i = 0; i < SURFACE_STEPS_U; i++) {
for (let j = 0; j < SURFACE_STEPS_V; j++) {
const current = i * (SURFACE_STEPS_V + 1) + j
const next = current + SURFACE_STEPS_V + 1
indices.push(current, next, current + 1, next, next + 1, current + 1)
}
}
const surfaceGeometry = new BufferGeometry()
surfaceGeometry.setAttribute("position", new Float32BufferAttribute(positions, 3))
surfaceGeometry.setIndex(indices)
surfaceGeometry.computeVertexNormals()
const surfaceMaterial = new MeshStandardMaterial({
color: SURFACE_COLOR,
roughness: 0.6,
side: DoubleSide,
transparent: true,
opacity: SURFACE_OPACITY,
// 裏側を通る線が透けるよう、深度は比較するが書かない
depthWrite: false
})
group.add(new Mesh(surfaceGeometry, surfaceMaterial))
// 曲面の上に引く線。u を一定にした線と v を一定にした線を、領域に引いた線と同じ本数だけ並べる。
// 曲がった線なので、細かく刻んだ折れ線としてつなぐ
const gridPoints: Vector3[] = []
const pushCurve = (at: (t: number) => Vector3) => {
for (let k = 0; k < CURVE_SAMPLES; k++) {
gridPoints.push(at(k / CURVE_SAMPLES), at((k + 1) / CURVE_SAMPLES))
}
}
for (let i = 0; i <= GRID_DIVISIONS; i++) {
const line = i / GRID_DIVISIONS
pushCurve((t) => evaluate(line, t, new Vector3()))
}
for (let j = 0; j <= GRID_DIVISIONS; j++) {
const line = j / GRID_DIVISIONS
pushCurve((t) => evaluate(t, line, new Vector3()))
}
const gridGeometry = new BufferGeometry().setFromPoints(gridPoints)
const gridMaterial = new LineBasicMaterial({ color: GRID_COLOR })
group.add(new LineSegments(gridGeometry, gridMaterial))
// 選んだ u・v に対応する 2 本の曲線。片方を固定して、もう片方を端から端まで動かす
const u = 0.28
const v = 0.62
const createIsoCurve = (color: string, at: (t: number) => Vector3) => {
const curvePoints: Vector3[] = []
for (let i = 0; i <= CURVE_SAMPLES; i++) {
curvePoints.push(at(i / CURVE_SAMPLES))
}
return new Line(new BufferGeometry().setFromPoints(curvePoints), new LineBasicMaterial({ color }))
}
group.add(createIsoCurve(FIXED_U_COLOR, (t) => evaluate(u, t, new Vector3())))
group.add(createIsoCurve(FIXED_V_COLOR, (t) => evaluate(t, v, new Vector3())))
// u と v の組を決めると、空間中の点が 1 つ決まる
const markerGeometry = new SphereGeometry(MARKER_RADIUS, 16, 12)
const markerMaterial = new MeshBasicMaterial({ color: MARKER_COLOR })
const marker = new Mesh(markerGeometry, markerMaterial)
marker.position.copy(evaluate(u, v, point))
group.add(marker)
// 曲面のふくらみを陰影でも読み取れるようにする光。向きは固定
const light = new DirectionalLight(LIGHT_COLOR, 2.5)
light.position.set(4, 5, 3)
scene.add(light, new AmbientLight(LIGHT_COLOR, 0.5))CGでは、曲線・曲面の多くがパラメータ形式、つまりパラメトリック曲線(曲面) で表されます。