mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-21 21:35:28 +02:00
feat(frontend): EXIFフレーム機能 (#16725)
* wip
* wip
* Update ImageEffector.ts
* Update image-label-renderer.ts
* Update image-label-renderer.ts
* wip
* Update image-label-renderer.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update use-uploader.ts
* Update watermark.ts
* wip
* wu
* wip
* Update image-frame-renderer.ts
* wip
* wip
* Update image-frame-renderer.ts
* Create ImageCompositor.ts
* Update ImageCompositor.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update use-uploader.ts
* wip
* wip
* wip
* wip
* Update fxs.ts
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.vue
* Update MkImageFrameEditorDialog.vue
* Update use-uploader.ts
* improve error handling
* Update use-uploader.ts
* 🎨
* wip
* wip
* lazy load
* lazy load
* wip
* wip
* wip
This commit is contained in:
332
packages/frontend/src/utility/watermark/WatermarkRenderer.ts
Normal file
332
packages/frontend/src/utility/watermark/WatermarkRenderer.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { url, host } from '@@/js/config.js';
|
||||
import { getProxiedImageUrl } from '../media-proxy.js';
|
||||
import { fn as fn_watermark } from './watermark.js';
|
||||
import { fn as fn_stripe } from '@/utility/image-compositor-functions/stripe.js';
|
||||
import { fn as fn_poladot } from '@/utility/image-compositor-functions/polkadot.js';
|
||||
import { fn as fn_checker } from '@/utility/image-compositor-functions/checker.js';
|
||||
import { ImageCompositor } from '@/lib/ImageCompositor.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
|
||||
|
||||
export type WatermarkLayers = ({
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
repeat: boolean;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'image';
|
||||
imageUrl: string | null;
|
||||
imageId: string | null;
|
||||
cover: boolean;
|
||||
repeat: boolean;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'qr';
|
||||
data: string;
|
||||
scale: number;
|
||||
align: Align;
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'stripe';
|
||||
angle: number;
|
||||
frequency: number;
|
||||
threshold: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'polkadot';
|
||||
angle: number;
|
||||
scale: number;
|
||||
majorRadius: number;
|
||||
majorOpacity: number;
|
||||
minorDivisions: number;
|
||||
minorRadius: number;
|
||||
minorOpacity: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'checker';
|
||||
angle: number;
|
||||
scale: number;
|
||||
color: [r: number, g: number, b: number];
|
||||
opacity: number;
|
||||
})[];
|
||||
|
||||
export type WatermarkPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
layers: WatermarkLayers;
|
||||
};
|
||||
|
||||
type WatermarkRendererImageCompositor = ImageCompositor<{
|
||||
watermark: typeof fn_watermark;
|
||||
stripe: typeof fn_stripe;
|
||||
polkadot: typeof fn_poladot;
|
||||
checker: typeof fn_checker;
|
||||
}>;
|
||||
|
||||
export class WatermarkRenderer {
|
||||
private compositor: WatermarkRendererImageCompositor;
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement,
|
||||
renderWidth: number,
|
||||
renderHeight: number,
|
||||
image: HTMLImageElement | ImageBitmap,
|
||||
}) {
|
||||
this.compositor = new ImageCompositor({
|
||||
canvas: options.canvas,
|
||||
renderWidth: options.renderWidth,
|
||||
renderHeight: options.renderHeight,
|
||||
image: options.image,
|
||||
functions: {
|
||||
watermark: fn_watermark,
|
||||
stripe: fn_stripe,
|
||||
polkadot: fn_poladot,
|
||||
checker: fn_checker,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async render(layers: WatermarkLayers) {
|
||||
const compositorLayers: Parameters<WatermarkRendererImageCompositor['render']>[0] = [];
|
||||
|
||||
const unused = new Set(this.compositor.getKeysOfRegisteredTextures());
|
||||
|
||||
for (const layer of layers) {
|
||||
if (layer.type === 'text') {
|
||||
const textureKey = `text:${layer.text}`;
|
||||
unused.delete(textureKey);
|
||||
if (!this.compositor.hasTexture(textureKey)) {
|
||||
if (_DEV_) console.log(`Baking text texture of <${textureKey}>...`);
|
||||
const image = await createTextureFromText(layer.text);
|
||||
if (image != null) this.compositor.registerTexture(textureKey, image);
|
||||
}
|
||||
|
||||
compositorLayers.push({
|
||||
functionId: 'watermark',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: layer.angle,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: textureKey,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'image') {
|
||||
const textureKey = `url:${layer.imageUrl}`;
|
||||
unused.delete(textureKey);
|
||||
if (!this.compositor.hasTexture(textureKey)) {
|
||||
if (_DEV_) console.log(`Baking url image texture of <${textureKey}>...`);
|
||||
const image = await createTextureFromUrl(layer.imageUrl);
|
||||
if (image != null) this.compositor.registerTexture(textureKey, image);
|
||||
}
|
||||
|
||||
compositorLayers.push({
|
||||
functionId: 'watermark',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: layer.angle,
|
||||
opacity: layer.opacity,
|
||||
cover: layer.cover,
|
||||
watermark: textureKey,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'qr') {
|
||||
const textureKey = `qr:${layer.data}`;
|
||||
unused.delete(textureKey);
|
||||
if (!this.compositor.hasTexture(textureKey)) {
|
||||
if (_DEV_) console.log(`Baking qr texture of <${textureKey}>...`);
|
||||
const image = await createTextureFromQr({ data: layer.data });
|
||||
if (image != null) this.compositor.registerTexture(textureKey, image);
|
||||
}
|
||||
|
||||
compositorLayers.push({
|
||||
functionId: 'watermark',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: false,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
angle: 0,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: textureKey,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'stripe') {
|
||||
compositorLayers.push({
|
||||
functionId: 'stripe',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
frequency: layer.frequency,
|
||||
threshold: layer.threshold,
|
||||
color: layer.color,
|
||||
opacity: layer.opacity,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'polkadot') {
|
||||
compositorLayers.push({
|
||||
functionId: 'polkadot',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
scale: layer.scale,
|
||||
majorRadius: layer.majorRadius,
|
||||
majorOpacity: layer.majorOpacity,
|
||||
minorDivisions: layer.minorDivisions,
|
||||
minorRadius: layer.minorRadius,
|
||||
minorOpacity: layer.minorOpacity,
|
||||
color: layer.color,
|
||||
},
|
||||
});
|
||||
} else if (layer.type === 'checker') {
|
||||
compositorLayers.push({
|
||||
functionId: 'checker',
|
||||
id: layer.id,
|
||||
params: {
|
||||
angle: layer.angle,
|
||||
scale: layer.scale,
|
||||
color: layer.color,
|
||||
opacity: layer.opacity,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of unused) {
|
||||
if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
|
||||
this.compositor.unregisterTexture(k);
|
||||
}
|
||||
|
||||
this.compositor.render(compositorLayers);
|
||||
}
|
||||
|
||||
public changeResolution(width: number, height: number) {
|
||||
this.compositor.changeResolution(width, height);
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true): void {
|
||||
this.compositor.destroy(disposeCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTextureFromUrl(imageUrl: string | null) {
|
||||
if (imageUrl == null || imageUrl.trim() === '') return null;
|
||||
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = getProxiedImageUrl(imageUrl); // CORS対策
|
||||
}).catch(() => null);
|
||||
|
||||
if (image == null) return null;
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
async function createTextureFromText(text: string | null, resolution = 2048) {
|
||||
if (text == null || text.trim() === '') return null;
|
||||
|
||||
const ctx = window.document.createElement('canvas').getContext('2d')!;
|
||||
ctx.canvas.width = resolution;
|
||||
ctx.canvas.height = resolution / 4;
|
||||
const fontSize = resolution / 32;
|
||||
const margin = fontSize / 2;
|
||||
ctx.shadowColor = '#000000';
|
||||
ctx.shadowBlur = fontSize / 4;
|
||||
|
||||
//ctx.fillStyle = '#00ff00';
|
||||
//ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillText(text, margin, ctx.canvas.height / 2);
|
||||
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
|
||||
const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
|
||||
const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), cropWidth, cropHeight);
|
||||
|
||||
ctx.canvas.remove();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createTextureFromQr(options: { data: string | null }, resolution = 512) {
|
||||
const $i = ensureSignin();
|
||||
|
||||
const qrCodeInstance = new QRCodeStyling({
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
margin: 42,
|
||||
type: 'canvas',
|
||||
data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
|
||||
image: $i.avatarUrl,
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'H',
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.3,
|
||||
margin: 16,
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
dotsOptions: {
|
||||
type: 'dots',
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
|
||||
if (blob == null) return null;
|
||||
|
||||
const image = await window.createImageBitmap(blob);
|
||||
|
||||
return image;
|
||||
}
|
||||
147
packages/frontend/src/utility/watermark/watermark.glsl
Normal file
147
packages/frontend/src/utility/watermark/watermark.glsl
Normal file
@@ -0,0 +1,147 @@
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
|
||||
in vec2 in_uv; // 0..1
|
||||
uniform sampler2D in_texture; // 背景
|
||||
uniform vec2 in_resolution; // 出力解像度(px)
|
||||
|
||||
uniform sampler2D u_watermark; // ウォーターマーク
|
||||
uniform vec2 u_wmResolution; // ウォーターマーク元解像度(px)
|
||||
|
||||
uniform float u_opacity; // 0..1
|
||||
uniform float u_scale; // watermarkのスケール
|
||||
uniform float u_angle; // -1..1 (PI倍)
|
||||
uniform bool u_cover; // cover基準 or fit基準
|
||||
uniform bool u_repeat; // タイル敷き詰め
|
||||
uniform int u_alignX; // 0:left 1:center 2:right
|
||||
uniform int u_alignY; // 0:top 1:center 2:bottom
|
||||
uniform float u_margin; // 余白(比率)
|
||||
uniform float u_repeatMargin; // 敷き詰め時の余白(比率)
|
||||
uniform bool u_noBBoxExpansion; // 回転時のBounding Box拡張を抑止
|
||||
uniform bool u_wmEnabled; // watermark有効
|
||||
|
||||
out vec4 out_color;
|
||||
|
||||
mat2 rot(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat2(c, -s, s, c);
|
||||
}
|
||||
|
||||
// cover/fitとscaleから、最終的なサイズ(px)を計算
|
||||
vec2 computeWmSize(vec2 outSize, vec2 wmSize, bool cover, float scale) {
|
||||
float wmAspect = wmSize.x / wmSize.y;
|
||||
float outAspect = outSize.x / outSize.y;
|
||||
vec2 size;
|
||||
if (cover) {
|
||||
if (wmAspect >= outAspect) {
|
||||
size.y = outSize.y * scale;
|
||||
size.x = size.y * wmAspect;
|
||||
} else {
|
||||
size.x = outSize.x * scale;
|
||||
size.y = size.x / wmAspect;
|
||||
}
|
||||
} else {
|
||||
if (wmAspect >= outAspect) {
|
||||
size.x = outSize.x * scale;
|
||||
size.y = size.x / wmAspect;
|
||||
} else {
|
||||
size.y = outSize.y * scale;
|
||||
size.x = size.y * wmAspect;
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 outSize = in_resolution;
|
||||
vec2 p = in_uv * outSize; // 出力のピクセル座標
|
||||
vec4 base = texture(in_texture, in_uv);
|
||||
|
||||
if (!u_wmEnabled) {
|
||||
out_color = base;
|
||||
return;
|
||||
}
|
||||
|
||||
float theta = u_angle * PI; // ラジアン
|
||||
vec2 wmSize = computeWmSize(outSize, u_wmResolution, u_cover, u_scale);
|
||||
vec2 margin = u_repeat ? wmSize * u_repeatMargin : outSize * u_margin;
|
||||
|
||||
// アライメントに基づく回転中心を計算
|
||||
float rotateX = 0.0;
|
||||
float rotateY = 0.0;
|
||||
if (abs(theta) > 1e-6 && !u_noBBoxExpansion) {
|
||||
rotateX = abs(abs(wmSize.x * cos(theta)) + abs(wmSize.y * sin(theta)) - wmSize.x) * 0.5;
|
||||
rotateY = abs(abs(wmSize.x * sin(theta)) + abs(wmSize.y * cos(theta)) - wmSize.y) * 0.5;
|
||||
}
|
||||
|
||||
float x;
|
||||
if (u_alignX == 1) {
|
||||
x = (outSize.x - wmSize.x) * 0.5;
|
||||
} else if (u_alignX == 0) {
|
||||
x = rotateX + margin.x;
|
||||
} else {
|
||||
x = outSize.x - wmSize.x - margin.x - rotateX;
|
||||
}
|
||||
|
||||
float y;
|
||||
if (u_alignY == 1) {
|
||||
y = (outSize.y - wmSize.y) * 0.5;
|
||||
} else if (u_alignY == 0) {
|
||||
y = rotateY + margin.y;
|
||||
} else {
|
||||
y = outSize.y - wmSize.y - margin.y - rotateY;
|
||||
}
|
||||
|
||||
vec2 rectMin = vec2(x, y);
|
||||
vec2 rectMax = rectMin + wmSize;
|
||||
vec2 rectCenter = (rectMin + rectMax) * 0.5;
|
||||
|
||||
vec4 wmCol = vec4(0.0);
|
||||
|
||||
if (u_repeat) {
|
||||
// アライメントに基づく中心で回転
|
||||
vec2 q = rectCenter + rot(theta) * (p - rectCenter);
|
||||
|
||||
// タイルグリッドの原点をrectMin(アライメント位置)に設定
|
||||
vec2 gridOrigin = rectMin - margin;
|
||||
vec2 qFromOrigin = q - gridOrigin;
|
||||
|
||||
// タイルサイズ(ウォーターマーク + マージン)で正規化
|
||||
vec2 tile = wmSize + margin * 2.0;
|
||||
vec2 tileUv = qFromOrigin / tile;
|
||||
|
||||
// タイル内のローカル座標(0..1)を取得
|
||||
vec2 localUv = fract(tileUv);
|
||||
|
||||
// ローカル座標をピクセル単位に変換
|
||||
vec2 localPos = localUv * tile;
|
||||
|
||||
// マージン領域内かチェック
|
||||
bool inMargin = any(lessThan(localPos, margin)) || any(greaterThanEqual(localPos, margin + wmSize));
|
||||
|
||||
if (!inMargin) {
|
||||
// ウォーターマーク領域内: UV座標を計算
|
||||
vec2 uvWm = (localPos - margin) / wmSize;
|
||||
wmCol = texture(u_watermark, uvWm);
|
||||
}
|
||||
// マージン領域の場合は透明(wmCol = vec4(0.0))のまま
|
||||
} else {
|
||||
// アライメントと回転に従い一枚だけ描画
|
||||
vec2 q = rectCenter + rot(theta) * (p - rectCenter);
|
||||
bool inside = all(greaterThanEqual(q, rectMin)) && all(lessThan(q, rectMax));
|
||||
if (inside) {
|
||||
vec2 uvWm = (q - rectMin) / wmSize;
|
||||
wmCol = texture(u_watermark, uvWm);
|
||||
}
|
||||
}
|
||||
|
||||
float a = clamp(wmCol.a * u_opacity, 0.0, 1.0);
|
||||
out_color = mix(base, vec4(wmCol.rgb, 1.0), a);
|
||||
}
|
||||
57
packages/frontend/src/utility/watermark/watermark.ts
Normal file
57
packages/frontend/src/utility/watermark/watermark.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import shader from './watermark.glsl';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
|
||||
export const fn = defineImageCompositorFunction<Partial<{
|
||||
cover: boolean;
|
||||
repeat: boolean;
|
||||
scale: number;
|
||||
angle: number;
|
||||
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
|
||||
opacity: number;
|
||||
noBoundingBoxExpansion: boolean;
|
||||
watermark: string | null;
|
||||
}>>({
|
||||
shader,
|
||||
main: ({ gl, u, params, textures }) => {
|
||||
// 基本パラメータ
|
||||
gl.uniform1f(u.opacity, params.opacity ?? 1.0);
|
||||
gl.uniform1f(u.scale, params.scale ?? 0.3);
|
||||
gl.uniform1f(u.angle, params.angle ?? 0.0);
|
||||
gl.uniform1i(u.cover, params.cover ? 1 : 0);
|
||||
gl.uniform1i(u.repeat, params.repeat ? 1 : 0);
|
||||
const ax = params.align?.x === 'left' ? 0 : params.align?.x === 'center' ? 1 : 2;
|
||||
const ay = params.align?.y === 'top' ? 0 : params.align?.y === 'center' ? 1 : 2;
|
||||
gl.uniform1i(u.alignX, ax);
|
||||
gl.uniform1i(u.alignY, ay);
|
||||
gl.uniform1f(u.margin, (params.align?.margin ?? 0));
|
||||
gl.uniform1f(u.repeatMargin, (params.align?.margin ?? 0));
|
||||
gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0);
|
||||
|
||||
// ウォーターマークテクスチャ
|
||||
const wm = textures.get(params.watermark);
|
||||
if (wm) {
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, wm.texture);
|
||||
|
||||
// リピートモードに応じてWRAP属性を設定
|
||||
if (params.repeat) {
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
||||
} else {
|
||||
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.uniform1i(u.watermark, 1);
|
||||
gl.uniform2f(u.wmResolution, wm.width, wm.height);
|
||||
gl.uniform1i(u.wmEnabled, 1);
|
||||
} else {
|
||||
gl.uniform1i(u.wmEnabled, 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user