画像フィルターを作ろう
スライド
画像フィルターの種類
CSSフィルター
Context2D のフィルター
WebGL のフィルター
CSS の特徴
軽い処理なら最高のパフォーマンス!
blur とかは普通に重いです
CSSを書くだけなので書くのも保守するのも楽ちん.
Context2D の特徴
描画の再利用が得意.高精細な画像にわずかな変化を与えるならこっち.WebGLだと毎フレーム再描画するか,変化していない箇所をフィードバックする必要があって重い.
単純な計算(O(n*log(n))程度)はできるので単純な処理ならこっち.
計算自体は単純でも行列が出てくるとコードがカオスになる(後述).
WebGL の特徴
並列処理が爆速.複雑な行列式を解く必要のあるフィルターや同時にたくさんの処理が走るパーティクルならこっち.
GPU を用いるので,単純な処理で呼び出すとアクセス時間そのものがボトルネックになり,かえって遅くなる.
点ではなく三角形の集合体で形を作るので,滑らかな曲線の表現は苦手.三角形の密度を増やすか描画結果にアンチエイリアスをかけることで解決可能だけど結構重くなる.
Context2D で作る
3行で
画像の入った ImageData と
空っぽの ImageData を使って
フィルターを掛けようとしてる
以下のコードは読まなくても大丈夫です.
code:sampleContext2D.ts
export default function drawFiltererdimage(
canvasId: string,
imageSrc: string,
YOUR_FILTER: IFilter
) : void {
// 空の ImageElement を作ります
const image = new Image()
// 画像読み込み
image.onload = () => {
// 画像サイズと等しい canvas を作ります
const canvas = document.getElementById(canvasId) as HTMLCanvasElement
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
// canvas に画像を載せます(正確にはコンテキストに画像データを渡します)
const context = canvas.getContext('2d') as CanvasRenderingContext2D
context.drawImage(image, 0, 0)
// src に既存の画像データを, dst に空の画像データを入れます
let src = context.getImageData(0, 0, canvas.width, canvas.height)
let dst = context.createImageData(canvas.width, canvas.height)
// いよいよフィルターを掛けます
YOUR_FILTER(src, dst, canvas)
// フィルター適用後の画像を canvas に載せます
context.putImageData(dst, 0, 0)
}
// 画像のリソースを読ませる. url だとクロスドメインエラーが出るのでローカルでやるのが無難
image.src = require(imageSrc)
}
こっから本題
たいていの場合,画像は「横 x 縦 x 色」の3次元で表現されます.(正確には,利用時には同次座標を合わせた4次元)
JavaScript の場合,画像は 「 R, G, B, α, R, G, B, α, R, G, B, α, R, G, B, α, ... 」のように1次元です.
そのため,たとえばグレースケール処理を施したいなら以下のようにします.
code:grayscale.ts
export default function grayscale(
src: ImageData,
dst: ImageData
): void {
for (let i = 0; i < src.data.length; i+= 4) {
// R, G, B それぞれを補正して”そのピクセルにふさわしい灰色"を作っています
const gray =
// ImageData.datai+3 は α(透明度) なので何も変更しません }
}
画像全体に等しく処理を掛けるような場合は Context2D でもそこそこ単純に記述することができます.
たとえば「ホバー時のみフィルター」として,フィルターを掛けたあとにホバーを外しても,画像を再描画するようなことにはならないので,処理の負荷もフィルターの計算量以上にはかかりにくいです.
次に,これは Context2D や webgl といった区分ではなく,JavaScript が画像データを配列で持つために面倒な書き方になる例を紹介します.
平均プーリング(銭湯のタイル絵のようにある区切り内で色を平均化する処理)といってぼかしの一種です.
以下コード
3行で
3層におよぶ for 文を使って
一定区間で区切って
区間内の色を平均化した
コードは読み飛ばしてもらって大丈夫です.
code:poolingAvg.js
/**
* 平均プーリング
*
* 本体なら区切り範囲の px を指定しますが
* 今回は定数として決め打ちしています.
* 計算量をなるべく抑えるため,冗長な書き方になりました.
*
* なお今回の画像サイズは 400x336 です.
*/
export default function poolingAvg (
src: ImageData,
dst: ImageData
): void {
/**
* 本来なら引数で渡されるであろう値.
* pooling_px: 区切り範囲の px です.
* image_width_index: 本来なら image.width * 4 を代入して定数にします.
*/
const POOLING_PX = 8
const IMAGE_WIDTH_INDEX = 1600
/**
* I. 8x8 で画像を区切ります.
*/
let keys: Array<number> = []
for (let i = 0; i < src.data.length; i++) {
if (i % 4 === 3) {
/**
* 1. 8px ずつ分割された行のうち 1px 目のインデックスを指定します.
* 2. 行の中の rgba のうち,8pxごとの r のインデックスを抜き出します.
*/
} else if (
i % (IMAGE_WIDTH_INDEX * POOLING_PX) < IMAGE_WIDTH_INDEX &&
i % (4 * POOLING_PX) === 0
) {
keys.push(i)
}
}
/**
* II. 区切り内の色を平均化します.
*/
/**
* 2階層以降のfor文は POOLING_PX に依存しますが
* これは 最上位の for文が依存する keys.length と反比例の関係にあり
* 計算量は,最良の条件では O(n) に,
* 最悪の条件(画像全体を平均化)では O(n^2) に近似できます.
*/
/** 下記 1~3 の処理を区切りごとに行います */
for (let i = 0; i < keys.length; i++) {
let r: number = 0
let g: number = 0
let b: number = 0
/** 1. 範囲内の色を rgb ごとに加えます */
for (let j = 0; j < (4 * POOLING_PX); j += 4) {
r += src.data[keysi + j + 0] g += src.data[keysi + j + 1] b += src.data[keysi + j + 2] for (let k = 1; k < POOLING_PX; k++) {
r += src.data[keysi + j + 0 + k * IMAGE_WIDTH_INDEX] g += src.data[keysi + j + 1 + k * IMAGE_WIDTH_INDEX] b += src.data[keysi + j + 2 + k * IMAGE_WIDTH_INDEX] }
}
/** 2. 加えた色を平均化します */
r = Math.ceil(r / (POOLING_PX * POOLING_PX))
g = Math.ceil(g / (POOLING_PX * POOLING_PX))
b = Math.ceil(b / (POOLING_PX * POOLING_PX))
/** 3. 完成した平均色を出力します */
for (let j = 0; j < (4 * POOLING_PX); j += 4) {
dst.data[keysi + j + 0] = r dst.data[keysi + j + 1] = g dst.data[keysi + j + 2] = b for (let k = 1; k < POOLING_PX; k++) {
dst.data[keysi + j + 0 + k * IMAGE_WIDTH_INDEX] = r dst.data[keysi + j + 1 + k * IMAGE_WIDTH_INDEX] = g dst.data[keysi + j + 2 + k * IMAGE_WIDTH_INDEX] = b }
}
}
}
src.data[i][j][*] = ... のように書ければどれだけ楽だったか.
一応 glMatrix などのライブラリもありますし,フルスクラッチでもまだ読める範囲ではあります.
行列計算ライブラリ
glMatrix
numJSは辞めた方がいい
更新が止まっている
ちなみにこのフィルターの計算量は最悪の条件下(画像全域の色を均一化)で O(n^2) です.
パフォーマンスに悪影響が出始めるラインなので,これより重いフィルターは Context2D では使いにくいでしょう.
次に WebGL でフィルターを掛けてみます.
一からお約束めいたコードを書くよりすんごくいい記事があったのでまずはそちらを紹介しておきます.
上記リンクは 生WebGLとThree.js なので,今回はPixi.jsでもう少し簡単なフィルターを取り挙げます.
本来は PIXI.AsciiFilter で用いることができますが,そうではなく中身をいじれるようにしました.
以下は Pixi.js のお約束コードなので無視してもらって大丈夫です.
単純に描画するだけなら 公式サンプル(//TODO: リンクをはる)があるので,今回はとりあえずコピペすれば Vue で試せるコードを挙げておきました.綺麗な画像が表示され,マウスホバー時にフィルターが掛かる仕様です.
code:YourComponent.vue
<template lang="pug">
div(@mouseover="filtered" @mouseleave="unfiltered")
</template>
<script lang="js">
/** tslint:disbale */
/** バージョン関連の厄介なエラーにハマったのでjsに戻しています */
import * as PIXI from "pixi.js";
import { TweenMax } from 'gsap'
import { YourFilter } from '@/filters/yourFilter'
export default {
name: "YourComponent",
data() {
return {
app: null,
renderer: null,
container: null,
sampleImage: null,
filter: null
}
},
mounted() {
this.app = new PIXI.Application({
antialias: true,
width: 612,
height: 600,
transparent: true,
resolution: window.devicePixelRatio || 1,
autoResize: true
});
this.$el.appendChild(this.app.view)
this.renderer = new PIXI.Renderer()
this.container = new PIXI.Container()
this.app.stage.addChild(this.container)
this.sampleImageSprite = new PIXI.Sprite(
)
this.filter = new YourFilter(
)
this.filter.scale.x = 0
this.filter.scale.y = 0
this.container.addChild(this.sampleImageSprite)
},
methods: {
filtered() {
TweenMax.to(this.filter.scale, 0.6, {
x: 100,
y: 100,
})
},
unfiltered() {
TweenMax.to(this.filter.scale, 0.6, {
x: 0,
y: 0
})
}
}
}
</script>
こっちも filter 適用するためのシェーダーを呼び出しているだけなので無視してもらって大丈夫です.
code:index.ts
// @/filters/yourFilter/index.js
import { Filter } from '@pixi/core';
import { vertex } from '@/filters/yourFilter/vertex.js';
import { fragment } from '@/filters/yourFilter/fragment.js';
export class YourFilter extends Filter {
constructor (size) {
super(
vertex,
fragment,
{ pixelSize: size }
);
}
}
WebGL(というかシェーダー) は vertex(頂点) と flagment(断片) という2種類のシェーダーで映像を表現します.詳細は WebGL:シェーダの記述と基礎 |wgld.org (//TOOD リンク)に任せるとして,ざっくり説明すると
こっちはおまじない.
code:vertex.ts
export default const vertex = '
// 頂点属性
attribute vec2 aVertexPosition; // 頂点の座標
attribute vec2 aTextureCoord; // 頂点のテクスチャ座標
uniform mat3 projectionMatrix; // カメラから見た座標を投影座標に変換する行列
varying vec2 vTextureCoord; // vertex - fragment 間の橋渡し
// 頂点の座標(mvp変換で出力)とテクスチャ座標を fragment シェーダに送る
void main(void) {
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
vTextureCoord = aTextureCoord;
}'
なんなら vertex を宣言せずに null で渡しても,Pixi側が初期値をいれてくれます.
ようやく本題の flagment シェーダーです.注目していただきたいのは void main() {} のところ
code:flagment.ts
export default const flagment = '
varying vec2 vTextureCoord; // vertex から渡されたテクスチャ座標
// new YourFilter() の引数. 全ての fragment に適用
uniform vec4 filterArea; // フィルターの適用範囲
uniform float pixelSize; // どれくらいの粒度で分割するか
uniform sampler2D uSampler; // PIXI.Texture
vec2 mapCoord ( vec2 coord ) {
coord *= filterArea.xy;
coord += filterArea.zw;
return coord;
}
vec2 unmapCoord ( vec2 coord ) {
coord -= filterArea.zw;
coord /= filterArea.xy;
return coord;
}
vec2 pixelate (vec2 coord, vec2 size) {
return floor( coord / size ) * size;
}
vec2 getMod (vec2 coord, vec2 size) {
return mod( coord , size) / size;
}
float character (float n, vec2 p) {
p = floor(p*vec2(4.0, -4.0) + 2.5);
if (clamp(p.x, 0.0, 4.0) == p.x) {
if (clamp(p.y, 0.0, 4.0) == p.y) {
if (int(mod(n/exp2(p.x + 5.0*p.y), 2.0)) == 1) return 1.0;
}
}
return 0.0;
}
void main() {
// テクスチャ座標を pixelSize で丸めて出力
vec2 coord = mapCoord(vTextureCoord);
vec2 pixCoord = pixelate(coord, vec2(pixelSize));
pixCoord = unmapCoord(pixCoord);
// テクスチャ情報とテクスチャの座標から色情報を取得(glslの組み込み関数)
vec4 color = texture2D(uSampler, pixCoord);
// 明るい箇所ほどより明るく見えやすい文字を適用
float gray = (color.r + color.g + color.b) / 3.0;
float n = 65536.0; // .
if (gray > 0.2) n = 65600.0; // :
if (gray > 0.3) n = 332772.0; // *
if (gray > 0.4) n = 15255086.0; // o
if (gray > 0.5) n = 23385164.0; // &
if (gray > 0.6) n = 15252014.0; // 8
if (gray > 0.7) n = 13199452.0; // @
if (gray > 0.8) n = 11512810.0; // #
// vec2(C/pixelSize) に収まるような modd を取得
vec2 modd = getMod(coord, vec2(pixelSize));
// 文字が描かれるべき部分は color*1, そうでない部分は color*0(つまり黒)
gl_FragColor = color * character( n, vec2(-1.0) + modd * 2.0);
}'
VSCode補完が効かない
FragmentShader書くとき楽な保管出るやつを提示する(めんどくさいから導入手順もセットで上げる)
JSだと文字列挿入
別拡張子で作って、webpackのloaderを噛ませてやる方法がいいかも
本格的にやりたい方
資格をとってみたい方
onuma.icon 一応エキスパートの資格持っているけど今の問題全然分からないですね・・・(て言うか電磁波エネルギーって普通に電磁気の問題w)