obs = obslua

TEXT_FILTER_NAME = '3D Block'

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_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_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_block'
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.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.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.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, 10, 100, 1)
    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_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 Block</h3>
    <p>Extrudes your source's alpha shape into a thick 3D block.</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 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;
}

// ===========================
//  helpers
// ===========================
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);
}

// ===========================
//  Alpha → SDF
// ===========================
float sampleAlpha(float2 uv) {
    if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) return 0.0;
    return image.Sample(def_sampler, uv).a;
}

// p_obj.xy は [-b.xy .. +b.xy]
float sdAlpha2D_obj(float2 p_xy_obj, float2 b_xy) {
    // [-b..b] → [0..1]
    float2 uv = (p_xy_obj / b_xy) * 0.5 + 0.5;
    uv = clamp(uv, 0.0, 1.0);

    // テクセルサイズ（UV単位）
    float2 texel = float2(
        1.0 / max(texture_width,  1.0),
        1.0 / max(texture_height, 1.0)
    );

    // ★ 中心＋左右の 3 サンプルだけ
    float aC = sampleAlpha(uv);
    float aL = sampleAlpha(uv - float2(texel.x, 0.0));
    float aR = sampleAlpha(uv + float2(texel.x, 0.0));

    // オブジェクト空間でのサンプル間隔（X方向）
    float dx_obj = 2.0 * b_xy.x * texel.x;
    dx_obj = max(dx_obj, 1e-5);

    // X方向の勾配だけを見る簡易版
    float gradX = (aR - aL) / max(2.0 * dx_obj, 1e-6);
    float g = abs(gradX);

    // SDF（a=0.5 を境界）
    float sd = (0.5 - aC) / max(g, 1e-3);

    // 勾配がほぼゼロな領域は、「中 or 外」として適当に厚みを持たせる
    if (g < 1e-3) {
        float inside = (aC >= 0.5) ? -1.0 : 1.0;
        sd = inside * 0.05 * min(b_xy.x, b_xy.y);
    }

    return sd;
}

/* 3D SDF：Alpha押し出しプリズム */
float sdExtrudedAlpha(float3 p_obj, float3 b) {
    float sd2 = sdAlpha2D_obj(p_obj.xy, b.xy);
    float dz = abs(p_obj.z) - b.z;
    return max(sd2, dz);
}

/* SDF 勾配から法線を計算 */
float3 calcNormalAlpha(float3 p, float3 b) {
    const float e = 0.0015;
    float3 ex = float3(e, 0.0, 0.0);
    float3 ey = float3(0.0, e, 0.0);
    float3 ez = float3(0.0, 0.0, e);
    float dx = sdExtrudedAlpha(p + ex, b) - sdExtrudedAlpha(p - ex, b);
    float dy = sdExtrudedAlpha(p + ey, b) - sdExtrudedAlpha(p - ey, b);
    float dz = sdExtrudedAlpha(p + ez, b) - sdExtrudedAlpha(p - ez, b);
    return normalize(float3(dx, dy, dz));
}

/* 面単位のフラットライト */
float flatLightFactor(float3 p, float3 b) {
    const float FRONT_INT = 1.00; // 正面
    const float BACK_INT = 0.50; // 背面
    const float SIDE_LIT = 0.80; // 側面・光が当たる側
    const float SIDE_SHADOW = 0.60; // 側面・影側

    // ── キャップ判定（正面/背面） ────────────────────────────────
    float zNorm = p.z / (b.z + 1e-4);
    const float CAP_TH = 0.92;

    if (zNorm >= CAP_TH) {
        return FRONT_INT;
    }
    if (zNorm <= -CAP_TH) {
        return BACK_INT;
    }

    // ── 側面 ────────────────────────────────
    // x: -b.x .. +b.x → u: 0..1 に正規化
    float xNorm = p.x / (b.x + 1e-4);
    float u = 0.5 * (xNorm + 1.0);
    u = saturate(u);
    // 少し滑らかなカーブにしておく
    float t = smoothstep(0.0, 1.0, u);

    float sideInt;

    if (light_position == 0) {
        // 左から光 → 左(0側)を明るく、右(1側)を暗く
        sideInt = lerp(SIDE_LIT, SIDE_SHADOW, t);
    } else {
        // 右から光 → 右を明るく、左を暗く
        sideInt = lerp(SIDE_SHADOW, SIDE_LIT, t);
    }

    return sideInt;
}

/* AABB（[-b..b]）との交差。ヒットなら t0,t1 を返す */
bool intersectAABB(float3 ro, float3 rd, float3 b, out float t0, out float t1) {
    float3 inv = 1.0 / rd;
    float3 tmin = (-b - ro) * inv;
    float3 tmax = ( b - ro) * inv;
    float3 t1v = min(tmin, tmax);
    float3 t2v = max(tmin, tmax);
    t0 = max( max(t1v.x, t1v.y), t1v.z );
    t1 = min( min(t2v.x, t2v.y), t2v.z );
    return t1 >= max(t0, 0.0);
}

// ===========================
//  メイン処理
// ===========================
float4 PSDraw(VertData v_in) : TARGET {
    float2 uv = v_in.uv;

    // 安全なアスペクト計算
    float w = max(texture_width,  1.0);
    float h = max(texture_height, 1.0);
    float aspect = w / h;
    
    // ありえない値は強制的に 1.0 付近に押し込む
    aspect = clamp(aspect, 0.25, 4.0);
    
    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);
    float ay = radians(tilt_y_deg);
    float 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.60;
        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);
    }

    float3 roObj = ro2 - woff;

    // まず AABB で t 範囲を絞る
    float t0, t1;
    if (!intersectAABB(roObj, rd2, b, t0, t1)) {
        return float4(0.0, 0.0, 0.0, 0.0);
    }

    // ===========================
    //  A. 一様ステップのレイマーチ（動的ステップ数）
    // ===========================
    const int MAX_STEPS = 64;
    
    // ベースは 40 ステップ
    int numSteps = 40;
    
    // 「厚み」「傾き」「画面上サイズ(scale)」
    float thickNorm = b.z;
    float tiltMag = max(abs(tilt_x_deg), abs(tilt_y_deg));
    float scaleOnScreen = scale;
    
    // ★ かなり薄いパネル → ステップ増やす
    if (thickNorm < 0.02) {
        numSteps = 64; // 薄いのでしっかり踏み抜き防止
    }
    else if (thickNorm < 0.04) {
        numSteps = 56;
    }
    // 逆に「かなり厚い・大きい・傾き強すぎない」なら少し減らしても OK
    else if (scaleOnScreen < 0.7 && tiltMag < 25.0) {
        numSteps = 32;
    }
    
    // 念のため下限を確保
    if (numSteps < 4) {
        numSteps = 4;
    }
    
    float dt = (t1 - t0) / float(numSteps);
    bool inside = false;
    float tHit = t1;

    // リング崩し用の jitter（軽量版）
    // uv を細かいグリッドに量子化して、それを整数ハッシュっぽく使う
    float2 grid = floor(uv * 1024.0); // 1024x1024 の格子にスナップ
    float jitterSeed = grid.x + grid.y * 57.0; // 適当な整数組み合わせ

    // sin なし・fbm なしの軽量乱数：単純な乗算 + frac だけ
    float rand = frac(jitterSeed * 0.1234567); // 0..1 程度の疑似ランダム

    // 中心を 0 にずらして強さを調整
    float jitter = (rand - 0.5) * dt * 0.35;

    // ループ回数の上限だけコンパイル時定数にしておく
    for (int i = 0; i < MAX_STEPS; i++) {
        if (i >= numSteps) break;

        float tCur = t0 + dt * (float(i) + 0.5) + jitter;
        if (tCur < t0 || tCur > t1) continue;

        float3 pObj = roObj + rd2 * tCur;
        float d = sdExtrudedAlpha(pObj, b);

        if (d <= 0.0) {
            inside = true;
            tHit = tCur;
            break;
        }
    }

    float2 texel = float2(1.0/max(texture_width,1.0), 1.0/max(texture_height,1.0));

    // ===========================
    //  B. レイマーチ成功パス
    // ===========================
    if (inside) {
        float3 pObjHit = roObj + rd2 * tHit;
    
        // ── 正面 / 背面 / 側面 の判定 ─────────────────
        float capFactor = abs(pObjHit.z) / (b.z + 1e-4); // 0..1
        const float CAP_TH = 0.96; // これより「端寄り」はフタ扱い
    
        bool isCap = (capFactor >= CAP_TH); // 正面 or 背面（フタ）
        float3 n = calcNormalAlpha(pObjHit, b);
        float frontDot = dot(n, float3(0.0, 0.0, 1.0));
        bool isBack = (frontDot < 0.0);
    
        // ── UV 計算 ────────────────────────────────
        float2 uvBase = (pObjHit.xy / b.xy) * 0.5 + 0.5;
        uvBase = clamp(uvBase, 0.0, 1.0);
    
        // 正面/背面用：縮小なし（端までピッタリ）
        float2 uvFront = uvBase;
    
        // 側面用：少しだけ内側を使う（ストライプ軽減用）
        float2 uvSide = uvBase * 0.995 + 0.005;
        uvSide = clamp(uvSide, 0.0, 1.0);
    
        // 正面テクスチャ
        float4 texFront = image.Sample(def_sampler, uvFront);
        float3 frontColor = texFront.rgb;
    
        // 側面色：中心＋4方向の最大α（uvSide 基準）
        float4 sC = image.Sample(def_sampler, uvSide);
        float4 sL1 = image.Sample(def_sampler, uvSide - float2(texel.x, 0.0));
        float4 sR1 = image.Sample(def_sampler, uvSide + float2(texel.x, 0.0));
        float4 sD1 = image.Sample(def_sampler, uvSide - float2(0.0, texel.y));
        float4 sU1 = image.Sample(def_sampler, uvSide + float2(0.0, texel.y));
    
        float bestA = sC.a;
        float3 bestCol = sC.rgb;
    
        if (sL1.a > bestA) { bestA = sL1.a; bestCol = sL1.rgb; }
        if (sR1.a > bestA) { bestA = sR1.a; bestCol = sR1.rgb; }
        if (sD1.a > bestA) { bestA = sD1.a; bestCol = sD1.rgb; }
        if (sU1.a > bestA) { bestA = sU1.a; bestCol = sU1.rgb; }
    
        float3 edgeColor = bestCol;
    
        float3 col;
    
        // ── カラー決定：front / back / side を完全に分離 ─────
        if (isCap) {
            // 正面/背面フタ部分
            if (isBack && has_back_image) {
                // 背面画像を uvFront ベースで貼る
                float2 v = uvFront - 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;
    
                float4 texBack;
                if (uvBack.x < 0.0 || uvBack.x > 1.0 ||
                    uvBack.y < 0.0 || uvBack.y > 1.0) {
                    texBack = float4(0.0, 0.0, 0.0, 1.0);
                } else {
                    texBack = back_image.Sample(def_sampler, uvBack);
                }
    
                col = texBack.rgb;
            } else {
                // 正面フタ
                col = frontColor;
            }
        } else {
            // 側面：edgeColor のみ
            col = edgeColor;
        }
    
        // 照明（面単位フラット）
        float lightF = flatLightFactor(pObjHit, b);
        col *= lightF;
    
        return float4(col, 1.0);
    }

    // ===========================
    //  C. レイマーチ失敗パス（フォールバック）
    // ===========================
    float viewCos = abs(rd2.z);

    // ほぼ横から見ているときはフォールバックしない
    if (viewCos < 0.6) {
        return float4(0.0, 0.0, 0.0, 0.0);
    }

    if (abs(rd2.z) < 1e-4) {
        return float4(0.0, 0.0, 0.0, 0.0);
    }

    // z=0 平面との交点
    float tPlane = -roObj.z / rd2.z;
    float3 pObjPlane = roObj + rd2 * tPlane;

    // この XY が 2D 的に外側なら塗らない（ツノ防止）
    float sdMid2D = sdAlpha2D_obj(pObjPlane.xy, b.xy);
    if (sdMid2D > 0.0) {
        return float4(0.0, 0.0, 0.0, 0.0);
    }

    // このXYから 2D 上の UV を復元
    float2 uvFront = (pObjPlane.xy / b.xy) * 0.5 + 0.5;
    uvFront = clamp(uvFront, 0.0, 1.0);

    // 近傍サンプルで「最大α」と「その色」を取る（★ こちらも 5 サンプルに削減）
    float4 sC2 = image.Sample(def_sampler, uvFront);
    float4 sL1b = image.Sample(def_sampler, uvFront - float2(texel.x, 0.0));
    float4 sR1b = image.Sample(def_sampler, uvFront + float2(texel.x, 0.0));
    float4 sD1b = image.Sample(def_sampler, uvFront - float2(0.0, texel.y));
    float4 sU1b = image.Sample(def_sampler, uvFront + float2(0.0, texel.y));

    float bestAb = sC2.a;
    float3 colFallback = sC2.rgb;

    if (sL1b.a > bestAb) { bestAb = sL1b.a; colFallback = sL1b.rgb; }
    if (sR1b.a > bestAb) { bestAb = sR1b.a; colFallback = sR1b.rgb; }
    if (sD1b.a > bestAb) { bestAb = sD1b.a; colFallback = sD1b.rgb; }
    if (sU1b.a > bestAb) { bestAb = sU1b.a; colFallback = sU1b.rgb; }

    if (bestAb < 0.02) {
        return float4(0.0, 0.0, 0.0, 0.0);
    }

    return float4(colFallback, 1.0);
}

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

]]