obs = obslua

TEXT_FILTER_NAME = '3D Panel'

TEXT_SCALE = '大きさ / Scale'
TEXT_TILT_X = '縦方向の傾き(X) / Tilt (X)'
TEXT_TILT_Y = '横方向の傾き(Y) / Tilt (Y)'
TEXT_TILT_Z = '回転(Z) / Roll (Z)'
TEXT_POS_X = '横位置 / Horizontal Position'
TEXT_POS_Y = '縦位置 / Vertical Position'
TEXT_THICK = '厚み / Thickness'
TEXT_R_FACE = '角丸 / Corner Radius'
TEXT_BRIGHTNESS = '明るさ / Brightness'
TEXT_LIGHT_POS = '照明の位置 / Light Direction'
TEXT_WIGGLE = 'ゆらゆら / Wiggle'
TEXT_WIGGLE_ROT = '角度もゆらゆら / Wiggle Rotation'
TEXT_BACK = '背面画像 / Back Texture'
TEXT_BACK_SCALE = '背面画像のサイズ / Back Texture Scale'

SETTING_SCALE = 'scale'
SETTING_TILT_X = 'tilt_x_deg'
SETTING_TILT_Y = 'tilt_y_deg'
SETTING_TILT_Z = 'tilt_z_deg'
SETTING_POS_X = 'pos_x_percent'
SETTING_POS_Y = 'pos_y_percent'
SETTING_THICK = 'thickness'
SETTING_R_FACE = 'radius_fb_corners'
SETTING_BRIGHTNESS = 'brightness'
SETTING_LIGHT_POS = 'light_position'
SETTING_WIGGLE = 'wiggle'
SETTING_WIGGLE_ROT = 'wiggle_rot'
SETTING_BACK = 'back_image_path'
SETTING_BACK_SCALE = 'back_scale'

source_def = {}
source_def.id = 'filter_3d_panel'
source_def.type = obs.OBS_SOURCE_TYPE_FILTER
source_def.output_flags = obs.OBS_SOURCE_VIDEO

local function set_render_size(filter)
    local target = obs.obs_filter_get_target(filter.context)
    if target == nil then
        filter.width, filter.height = 0, 0
        return
    end
    local w = obs.obs_source_get_base_width(target)
    local h = obs.obs_source_get_base_height(target)
    if filter.width ~= w or filter.height ~= h then
        filter.width, filter.height = w, h
    end
end

source_def.get_name = function() return TEXT_FILTER_NAME end

local function reload_back_image(filter)
    -- OBS のグラフィックスコンテキストに入る
    obs.obs_enter_graphics()

    -- 既存の画像があれば解放
    if filter.back_image ~= nil then
        obs.gs_image_file_free(filter.back_image)
        filter.back_image = nil
    end

    filter.back_aspect = 1.0

    local path = filter.back_image_path
    if path ~= nil and path ~= "" then
        -- 画像オブジェクトを生成
        local img = obs.gs_image_file()
        -- ファイルを読み込む（CPU側）
        obs.gs_image_file_init(img, path)

        if img.loaded then
            -- GPUテクスチャとして初期化
            obs.gs_image_file_init_texture(img)
            -- 成功したので保持
            filter.back_image = img

            -- 画像のアスペクト比を記録
            if img.cx ~= nil and img.cy ~= nil and img.cx ~= 0 then
                filter.back_aspect = img.cy / img.cx
            else
                filter.back_aspect = 1.0
            end
        else
            -- 読み込み失敗時はログ出して解放
            obs.blog(obs.LOG_WARNING,
                string.format("[3D Panel] Failed to load back image: %s", path))
            obs.gs_image_file_free(img)
        end
    end

    -- グラフィックスコンテキストを抜ける
    obs.obs_leave_graphics()
end

source_def.create = function(settings, source)
    local filter = { params = {}, context = source }
    set_render_size(filter)

    obs.obs_enter_graphics()
    filter.effect = obs.gs_effect_create(shader, nil, nil)
    if filter.effect ~= nil then
        filter.params.texture_width = obs.gs_effect_get_param_by_name(filter.effect, 'texture_width')
        filter.params.texture_height = obs.gs_effect_get_param_by_name(filter.effect, 'texture_height')
        filter.params.scale = obs.gs_effect_get_param_by_name(filter.effect, 'scale')
        filter.params.tilt_x_deg = obs.gs_effect_get_param_by_name(filter.effect, 'tilt_x_deg')
        filter.params.tilt_y_deg = obs.gs_effect_get_param_by_name(filter.effect, 'tilt_y_deg')
        filter.params.tilt_z_deg = obs.gs_effect_get_param_by_name(filter.effect, 'tilt_z_deg')
        filter.params.pos_x = obs.gs_effect_get_param_by_name(filter.effect, 'pos_x')
        filter.params.pos_y = obs.gs_effect_get_param_by_name(filter.effect, 'pos_y')
        filter.params.thickness = obs.gs_effect_get_param_by_name(filter.effect, 'thickness')
        filter.params.light_position = obs.gs_effect_get_param_by_name(filter.effect, 'light_position')
        filter.params.radius_fb = obs.gs_effect_get_param_by_name(filter.effect, 'radius_fb')
		filter.params.brightness = obs.gs_effect_get_param_by_name(filter.effect, 'brightness')
        filter.params.time = obs.gs_effect_get_param_by_name(filter.effect, 'time')
        filter.params.wiggle = obs.gs_effect_get_param_by_name(filter.effect, 'wiggle')
        filter.params.wiggle_rot = obs.gs_effect_get_param_by_name(filter.effect, 'wiggle_rot')
        filter.params.back_image = obs.gs_effect_get_param_by_name(filter.effect, 'back_image')
        filter.params.has_back_image = obs.gs_effect_get_param_by_name(filter.effect, 'has_back_image')
        filter.params.back_scale = obs.gs_effect_get_param_by_name(filter.effect, 'back_scale')
        filter.params.back_aspect = obs.gs_effect_get_param_by_name(filter.effect, 'back_aspect')
    end
    obs.obs_leave_graphics()

    if filter.effect == nil then
        source_def.destroy(filter)
        return nil
    end

    source_def.update(filter, settings)
    return filter
end

source_def.destroy = function(filter)
    if not filter then return end

    obs.obs_enter_graphics()
    if filter.effect ~= nil then
        obs.gs_effect_destroy(filter.effect)
        filter.effect = nil
    end
    if filter.back_image ~= nil then
        obs.gs_image_file_free(filter.back_image)
        filter.back_image = nil
    end
    obs.obs_leave_graphics()
end

source_def.update = function(filter, settings)
    filter.scale = obs.obs_data_get_double(settings, SETTING_SCALE) * 0.01
    filter.tilt_x_deg = obs.obs_data_get_double(settings, SETTING_TILT_X)
    filter.tilt_y_deg = obs.obs_data_get_double(settings, SETTING_TILT_Y)
    filter.tilt_z_deg = obs.obs_data_get_double(settings, SETTING_TILT_Z)
    filter.pos_x = (obs.obs_data_get_double(settings, SETTING_POS_X) or 0) * 0.01
    filter.pos_y = (obs.obs_data_get_double(settings, SETTING_POS_Y) or 0) * 0.01
    filter.thickness = obs.obs_data_get_int(settings, SETTING_THICK) * 0.001
    filter.radius_fb = obs.obs_data_get_int(settings, SETTING_R_FACE) * 0.01
	filter.brightness = obs.obs_data_get_double(settings, SETTING_BRIGHTNESS)
    filter.light_position = obs.obs_data_get_int(settings, SETTING_LIGHT_POS)
    filter.wiggle = obs.obs_data_get_int(settings, SETTING_WIGGLE) * 0.025
    filter.wiggle_rot = obs.obs_data_get_bool(settings, SETTING_WIGGLE_ROT)
    filter.back_image_path = obs.obs_data_get_string(settings, SETTING_BACK)
    filter.back_scale = obs.obs_data_get_double(settings, SETTING_BACK_SCALE) * 0.01
    reload_back_image(filter)

    if filter.params.scale then obs.gs_effect_set_float(filter.params.scale, filter.scale) end
    if filter.params.tilt_x_deg then obs.gs_effect_set_float(filter.params.tilt_x_deg, filter.tilt_x_deg) end
    if filter.params.tilt_y_deg then obs.gs_effect_set_float(filter.params.tilt_y_deg, filter.tilt_y_deg) end
    if filter.params.tilt_z_deg then obs.gs_effect_set_float(filter.params.tilt_z_deg, filter.tilt_z_deg) end
    if filter.params.pos_x then obs.gs_effect_set_float(filter.params.pos_x, filter.pos_x) end
    if filter.params.pos_y then obs.gs_effect_set_float(filter.params.pos_y, filter.pos_y) end
    if filter.params.thickness then obs.gs_effect_set_float(filter.params.thickness, filter.thickness) end
    if filter.params.radius_fb then obs.gs_effect_set_float(filter.params.radius_fb, filter.radius_fb) end
	if filter.params.brightness then obs.gs_effect_set_float(filter.params.brightness, filter.brightness) end
    if filter.params.light_position then obs.gs_effect_set_int(filter.params.light_position, filter.light_position) end
    if filter.params.wiggle then obs.gs_effect_set_float(filter.params.wiggle, filter.wiggle) end
    if filter.params.wiggle_rot then  obs.gs_effect_set_bool(filter.params.wiggle_rot, filter.wiggle_rot) end
    local has = filter.back_image ~= nil and filter.back_image.texture ~= nil
    obs.gs_effect_set_bool(filter.params.has_back_image, has)
    if filter.params.back_scale then obs.gs_effect_set_float(filter.params.back_scale, filter.back_scale) end
    if filter.params.back_aspect then obs.gs_effect_set_float(filter.params.back_aspect, filter.back_aspect or 1.0) end

    set_render_size(filter)
end

source_def.video_render = function(filter, effect)
    if not obs.obs_source_process_filter_begin(filter.context, obs.GS_RGBA, obs.OBS_NO_DIRECT_RENDERING) then
        return
    end

    if filter.width and filter.height then
        obs.gs_effect_set_float(filter.params.texture_width,  filter.width)
        obs.gs_effect_set_float(filter.params.texture_height, filter.height)
    end

    -- 背面テクスチャをバインド
    if filter.params.back_image ~= nil and filter.back_image ~= nil then
        local tex = filter.back_image.texture
        if tex ~= nil then
            obs.gs_effect_set_texture(filter.params.back_image, tex)
        end
    end

    obs.obs_source_process_filter_end(filter.context, filter.effect, filter.width, filter.height)
end

source_def.get_properties = function(settings)
    local props = obs.obs_properties_create()
    obs.obs_properties_add_float_slider(props, SETTING_SCALE, TEXT_SCALE, 25.0, 300.0, 0.1)
    obs.obs_properties_add_float_slider(props, SETTING_TILT_X, TEXT_TILT_X, -360.0, 360.0, 0.1)
    obs.obs_properties_add_float_slider(props, SETTING_TILT_Y, TEXT_TILT_Y, -360.0, 360.0, 0.1)
    obs.obs_properties_add_float_slider(props, SETTING_TILT_Z, TEXT_TILT_Z, -360.0, 360.0, 0.1)
    obs.obs_properties_add_float_slider(props, SETTING_POS_X, TEXT_POS_X, -100.0, 100.0, 0.01)
    obs.obs_properties_add_float_slider(props, SETTING_POS_Y, TEXT_POS_Y, -100.0, 100.0, 0.01)
    obs.obs_properties_add_int_slider(props, SETTING_THICK, TEXT_THICK, 0, 100, 1)
    obs.obs_properties_add_int_slider(props, SETTING_R_FACE, TEXT_R_FACE, 0, 100, 1)
	obs.obs_properties_add_float_slider(props, SETTING_BRIGHTNESS, TEXT_BRIGHTNESS, 0.0, 2.0, 0.01)
    local p = obs.obs_properties_add_list(props, SETTING_LIGHT_POS, TEXT_LIGHT_POS, obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT)
    obs.obs_property_list_add_int(p, '左側に光 / Light From Left', 0)
    obs.obs_property_list_add_int(p, '右側に光 / Light From Right', 1)
    obs.obs_properties_add_int_slider(props, SETTING_WIGGLE, TEXT_WIGGLE, 0, 100, 1)
    obs.obs_properties_add_bool(props, SETTING_WIGGLE_ROT, TEXT_WIGGLE_ROT)
    obs.obs_properties_add_path(props, SETTING_BACK, TEXT_BACK, obs.OBS_PATH_FILE, 'Image Files (*.png *.jpg *.jpeg *.bmp);;All Files (*.*)', nil)
    obs.obs_properties_add_float_slider(props, SETTING_BACK_SCALE, TEXT_BACK_SCALE, 10.0, 300.0, 1.0)
    return props
end

source_def.get_defaults = function(settings)
    obs.obs_data_set_default_double(settings, SETTING_SCALE, 100.0)
    obs.obs_data_set_default_double(settings, SETTING_TILT_X, 20.0)
    obs.obs_data_set_default_double(settings, SETTING_TILT_Y, 35.0)
    obs.obs_data_set_default_double(settings, SETTING_TILT_Z, 0.0)
    obs.obs_data_set_default_double(settings, SETTING_POS_X, 0.0)
    obs.obs_data_set_default_double(settings, SETTING_POS_Y, 0.0)
    obs.obs_data_set_default_int(settings, SETTING_THICK, 30)
    obs.obs_data_set_default_int(settings, SETTING_R_FACE, 20)
	obs.obs_data_set_default_double(settings, SETTING_BRIGHTNESS, 1.2)
    obs.obs_data_set_default_int(settings, SETTING_LIGHT_POS, 0)
    obs.obs_data_set_default_int(settings, SETTING_WIGGLE, 0)
    obs.obs_data_set_default_bool(settings, SETTING_WIGGLE_ROT, false)
    obs.obs_data_set_default_double(settings, SETTING_BACK_SCALE, 100.0)
end

source_def.video_tick = function(filter, seconds)
    set_render_size(filter)
    filter.time = (filter.time or 0.0) + seconds
    if filter.effect and filter.params.time then
        obs.gs_effect_set_float(filter.params.time, filter.time)
    end
end

source_def.get_width  = function(filter) return filter.width  end
source_def.get_height = function(filter) return filter.height end

function script_description()
    return [[<h3>3D Panel</h3>
    <p>This effect filter transforms your OBS sources into thick, 3D panels.</p>
    <p>┈┈┈┈┈┈✂<br>by <a href="https://x.com/HoraiChan">蓬莱軒</a></p>]]
end

function script_load(settings) obs.obs_register_source(source_def) end


shader = [[

uniform float4x4 ViewProj;
uniform texture2d image;
uniform float texture_width;
uniform float texture_height;

uniform float time;
uniform float wiggle;
uniform float scale;
uniform float tilt_x_deg;
uniform float tilt_y_deg;
uniform float tilt_z_deg;
uniform float pos_x;
uniform float pos_y;
uniform float thickness;
uniform float radius_fb;
uniform float brightness;
uniform int light_position;
uniform bool wiggle_rot;
uniform texture2d back_image;
uniform bool has_back_image;
uniform float back_scale;
uniform float back_aspect;

sampler_state def_sampler {
    Filter   = Linear;
    AddressU = Clamp;
    AddressV = Clamp;
};

struct VertData { float4 pos:POSITION; float2 uv:TEXCOORD0; };

VertData VSDefault(VertData v_in) {
    VertData o;
    o.pos = mul(float4(v_in.pos.xyz, 1.0), ViewProj);
    o.uv  = v_in.uv;
    return o;
}

float hash1(float n){ return frac(sin(n)*43758.5453123); }

float noise1D(float x) {
    float i = floor(x);
    float f = frac(x);
    float u = f*f*(3.0 - 2.0*f);
    return lerp(hash1(i), hash1(i+1.0), u); // 0..1
}

float fbm1D(float x) {
    float v = 0.0;
    float a = 0.5;
    float f = 1.0;
    for(int k=0;k<4;k++){
        v += a * noise1D(x * f);
        f *= 2.0;
        a *= 0.5;
    }
    return v;
}

float saturate(float x) { return clamp(x, 0.0, 1.0); }

float3 rotateX(float3 p, float a){ float c=cos(a), s=sin(a); return float3(p.x, c*p.y - s*p.z, s*p.y + c*p.z); }
float3 rotateY(float3 p, float a){ float c=cos(a), s=sin(a); return float3( c*p.x + s*p.z, p.y, -s*p.x + c*p.z); }
float3 rotateZ(float3 p, float a){ float c=cos(a), s=sin(a); return float3(c*p.x - s*p.y, s*p.x + c*p.y, p.z); }

// 2D 角丸長方形 SDF（中心、半径 bxy, 角丸 r）
float sdRoundRect2D(float2 p, float2 bxy, float r) {
    float2 q = abs(p) - bxy + r;
    return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}

// 正面シルエット角丸 + Z方向に押し出し
float sdFrontViewRoundedPrism(float3 p, float3 b, float r_fb_norm) {
    float r_fb = saturate(r_fb_norm) * (0.999 * min(b.x, b.y));
    float a = sdRoundRect2D(p.xy, b.xy, r_fb);
    float dz = abs(p.z) - b.z;
    return max(a, dz);
}

// 法線
float3 calcNormal(float3 p, float3 b, float rfb) {
    const float e = 0.001;
    float3 ex=float3(e,0,0), ey=float3(0,e,0), ez=float3(0,0,e);
    float dx = sdFrontViewRoundedPrism(p+ex,b,rfb) - sdFrontViewRoundedPrism(p-ex,b,rfb);
    float dy = sdFrontViewRoundedPrism(p+ey,b,rfb) - sdFrontViewRoundedPrism(p-ey,b,rfb);
    float dz = sdFrontViewRoundedPrism(p+ez,b,rfb) - sdFrontViewRoundedPrism(p-ez,b,rfb);
    return normalize(float3(dx,dy,dz));
}

// 照明
float3 shade(float3 n, float3 v) {
    float3 l;
    if (light_position == 0) {
        // 左から光
        l = normalize(float3(-1.0, -0.1, 1.0));
    }
    else {
        // 右から光
        l = normalize(float3( 1.0, -0.1, 1.0));
    }
    float diff = saturate(dot(n,l));
    float rim = pow(1.0 - saturate(dot(n,v)), 2.0);
    float li = 0.25 + 0.75*diff + 0.08*rim;
    return float3(li, li, li);
}

float4 PSDraw(VertData v_in) : TARGET {
    float2 uv = v_in.uv;

    // 画面座標（短辺基準）
    float aspect = texture_width / texture_height;
    float2 ndc = uv * 2.0 - 1.0;
    ndc += float2(pos_x, pos_y) * -1.0 * (scale + 1.0);
    float2 p2 = ndc;
    p2.x *= aspect;

    // カメラ設定
    float3 ro = float3(0.0, 0.0, 3.2);
    float3 rd = normalize(float3(p2, -4.0));

    // 回転（Z→Y→X の順に逆回転）
    float ax=radians(tilt_x_deg), ay=radians(tilt_y_deg), az=radians(tilt_z_deg);
    ro = rotateX(rotateY(rotateZ(ro, -az), -ay), -ax);
    rd = normalize(rotateX(rotateY(rotateZ(rd, -az), -ay), -ax));

    // 画面フィット（短辺基準）＋ 厚み
    float2 baseXY;
    if (aspect > 1.0) {
        baseXY = float2(1.0, 1.0 / aspect);
    }
    else {
        const float portraitMargin = 0.6;
        baseXY = float2(aspect * portraitMargin, 1.0 * portraitMargin);
    }
    float3 b = float3(baseXY, thickness) * max(scale, 0.0001);

    // Wiggle
    float diag = length(2.0 * b);
    float amp  = 0.05 * wiggle * diag;
    const float WSPD = 0.1;
    
    // 各軸に独立ノイズ
    float wx = (fbm1D(time*WSPD + 13.37) * 2.0 - 1.0) * amp;
    float wy = (fbm1D(time*WSPD + 47.11) * 2.0 - 1.0) * amp;
    float wz = (fbm1D(time*WSPD + 91.73) * 2.0 - 1.0) * amp * 0.35;
    float3 woff = float3(wx, wy, wz);

    float rotAmp = radians(12.0) * wiggle;
    
    float wobX = (fbm1D(time*WSPD + 128.31) * 2.0 - 1.0) * rotAmp;
    float wobY = (fbm1D(time*WSPD + 299.91) * 2.0 - 1.0) * rotAmp;
    
    float3 ro2 = ro;
    float3 rd2 = rd;
    
    if (wiggle_rot) {
        ro2 = rotateX(ro2, wobX);
        ro2 = rotateY(ro2, wobY);
        rd2 = rotateX(rd2, wobX);
        rd2 = rotateY(rd2, wobY);
    }

    float t = 0.0;
    float d = 0.0;
    bool hit = false;
    for (int i=0; i<64; i++) {
        float3 pos = ro2 + rd2 * t;
        d = sdFrontViewRoundedPrism(pos - woff, b, radius_fb);
        if (d < 0.001) { hit = true; break; }
        t += d;
        if (t > 8.0) break;
    }

    // ヒットしなければ完全透明（元ソースは非表示）
    if (!hit) return float4(0.0, 0.0, 0.0, 0.0);

    float3 pos = ro2 + rd2 * t;
    float3 pObj = pos - woff;
    float3 n = calcNormal(pObj, b, radius_fb);
    float3 vdir = normalize(-rd2);

    // テクスチャ貼り付け
    float frontDot  = dot(n, float3(0.0, 0.0, 1.0));
    float frontMask = smoothstep(0.5, 0.8, frontDot);
    bool isBack = (frontDot < 0.0);
    float2 uvTex = (pObj.xy / b.xy) * 0.5 + 0.5;
    float4 tex;

    // サンプル
    float4 texFront = image.Sample(def_sampler, uvTex);
    float4 texEdge = image.Sample(def_sampler, uvTex);

    if (isBack && has_back_image) {
        // 背面画像ありの描画
        float2 v = uvTex - 0.5;
        float panel_aspect = b.x / b.y;
        float img_aspect = max(1e-6, 1.0 / back_aspect); // width / height
        float ratio = img_aspect / panel_aspect;

        // cover 相当になるように短辺基準でフィット
        if (ratio > 1.0) {
            v.x /= ratio;
        }
        else {
            v.y *= ratio;
        }
        float s = max(back_scale, 0.001);
        v /= s;
        float2 uvBack = v + 0.5;
        uvBack.x = 1.0 - uvBack.x;

        if (uvBack.x < 0.0 || uvBack.x > 1.0 ||
            uvBack.y < 0.0 || uvBack.y > 1.0) {
            // 範囲外は黒、範囲内だけサンプル
            tex = float4(0.0, 0.0, 0.0, 1.0);
        }
        else {
            // ここはそのまま 2D サンプル
            tex = back_image.Sample(def_sampler, uvBack);
        }
    }
    else {
        tex = lerp(texEdge, texFront, frontMask);
    }

    // 前後の面のエッジ・ハイライト（細い線）
    float r_fb = saturate(radius_fb) * (0.999 * min(b.x, b.y));
    float a_xy = sdRoundRect2D(pObj.xy, b.xy, r_fb); // XY角丸SDF
    float edgeWidth = 0.02 * min(b.x, b.y);
    float edgeIntensity = 0.6;
    float edgeProx = 1.0 - saturate(abs(a_xy) / edgeWidth);
    float faceMask = smoothstep(0.5, 0.8, abs(frontDot));
    float edgeMask = faceMask * edgeProx;
    tex.rgb *= (1.0 + edgeMask * edgeIntensity);

    // 照明
    float3 lightTerm = shade(n, vdir);
    tex.rgb *= lightTerm;

    // 明るさ
    tex.rgb *= brightness;

    // 出力
    return float4(tex.rgb, 1.0);
}

technique Draw {
    pass {
        vertex_shader = VSDefault(v_in);
        pixel_shader  = PSDraw(v_in);
    }
}

]]