阅读视图

发现新文章,点击刷新页面。

Solidity 0.8.27 + OZ V5 实战:构建 AI 驱动的去中心化预测市场核心合约

前言

本文将采用 Solidity 0.8.27+ 编译器及最新的 OpenZeppelin Contracts V5 工业级标准库,利用这套能实现极致 Gas 优化与资金安全的前沿现代化 EVM 技术栈,构建并测试一个高性能的去中心化预测市场核心智能合约;同时,本文还将深度剖析 Rain 协议与行业龙头 Polymarket 的本质区别。


一、 行业格局:Rain Protocol 与 Polymarket 的本质区别

一句话总结:Polymarket 是预测市场里的“亚马逊”(面向C端用户的成品App),而 Rain Protocol 则是预测市场里的“Shopify”(面向B端开发者的开店工具)。

核心区别如下:

  • 业务定位:Polymarket 是独立的对赌平台(To C);Rain 是让任何项目、社区一键内嵌预测功能的底层基础设施(To B)。
  • 代币结算:Polymarket 长期绑定 USDC 等主流稳定币;Rain 原生支持多代币,允许任何社区用自己的项目代币或 Meme 币进行结算。
  • 裁判机制:Polymarket 依赖 UMA 人类预言机仲裁,结算需数小时或数天;Rain 深度整合 AI 自动化预言机,秒级解析全网数据,实现秒级高频结算。

二、 技术栈核心选型优势

为了支撑 Rain 协议所需的多代币高频交互AI 秒级自动化结算,顶尖的区块链项目在底层架构上都会不约而同地选择 Solidity 0.8.27 + OZ V5 这套工业级高标组合:

  • 极致的 Gas 优化(Custom Errors 替代 Require) :在 L2 网络中,由于预测市场涉及高频的创建、下注与清算,传统的 require(condition, "Error String") 会因存储冗长字符串而产生不必要的执行开销。Solidity 0.8.27 配合 OpenZeppelin V5 全面拥抱了 custom error(自定义错误),编译时仅保留 4 字节的选择器(Selector),为高频用户最大化榨干 L2 的 Gas 费率红利。

  • OpenZeppelin V5 架构演进

    • 显式所有权初始化:V5 版本的 Ownable 彻底废弃了在构造函数中暗中将 msg.sender 设为管理员的隐式设计。现在它强行要求在构造时显式传入 _initialOwner,大幅提升了通过“工厂合约(Factory Pattern)”跨链动态部署无数个预测市场时的安全性。
    • 模块化安全性:全新的 ReentrancyGuard(防重入)和 SafeERC20 针对防假充值漏洞、多代币兼容性以及降低存储插槽(Storage Slot)开销进行了底层重写。
  • EVM 现代指令集适配:高版本的编译器能完美利用 Cancun(坎昆)或更先进的硬分叉指令集(如 MCOPY 内存拷贝指令),使复杂的动态数组操作或批量多代币清算逻辑的执行效率达到极致。


三、 核心智能合约实现:PredictionMarket.sol

以下是基于 Rain 协议核心逻辑精简并升级的智能合约。它实现了预测市场的创建、用户对不同结局(YES/NO)的对赌下注、管理员/预言机结算、以及根据投注比例完美清算分红的核心流程:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

// 引入 OpenZeppelin V5 标准库
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title Rain 预测市场核心合约
 * @notice 基于 Solidity 0.8.27 + OpenZeppelin V5 构建
 */
contract RainPredictionMarket is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    // ==========================================
    // 状态变量与类型定义
    // ==========================================
    
    IERC20 public immutable bettingToken; // 预测市场使用的代币(如 USDC)

    enum MarketState { Open, Closed, Resolved, Cancelled }
    enum Outcome { None, Yes, No }

    struct Market {
        string description;       // 市场描述(如:2026年某事件是否发生)
        uint256 endTime;          // 下注截止时间戳
        uint256 totalYesBets;     // YES 资金池总量
        uint256 totalNoBets;      // NO 资金池总量
        MarketState state;        // 市场当前状态
        Outcome winner;           // 最终结算结果
    }

    uint256 public marketCount;
    mapping(uint256 => Market) public markets;
    
    // 记录用户下注额度: marketId => userAddress => Outcome => amount
    mapping(uint256 => mapping(address => mapping(Outcome => uint256))) public userBets;

    // ==========================================
    // OpenZeppelin V5 自定义错误 (Custom Errors)
    // ==========================================
    error MarketNotOpen();
    error BettingEnded();
    error InvalidOutcome();
    error ZeroAmount();
    error MarketNotResolved();
    error NoWinningShares();
    error ArrayLengthMismatch();

    // ==========================================
    // 事件 (Events)
    // ==========================================
    event MarketCreated(uint256 indexed marketId, string description, uint256 endTime);
    event BetPlaced(uint256 indexed marketId, address indexed user, Outcome indexed outcome, uint256 amount);
    event MarketResolved(uint256 indexed marketId, Outcome indexed winner);
    event MarketCancelled(uint256 indexed marketId);
    event RewardClaimed(uint256 indexed marketId, address indexed user, uint256 amount);

    // ==========================================
    // 构造函数 (Constructor)
    // ==========================================
    // 注意:OpenZeppelin V5 的 Ownable 需要将初始所有者地址显示传递给其构造函数
    constructor(address _bettingToken, address _initialOwner) Ownable(_initialOwner) {
        bettingToken = IERC20(_bettingToken);
    }

    // ==========================================
    // 核心业务函数 (Core Functions)
    // ==========================================

    /**
     * @notice 创建一个新的预测市场
     * @param _description 市场描述
     * @param _durationFromNow 从现在起至截止下注的秒数
     */
    function createMarket(string calldata _description, uint256 _durationFromNow) external onlyOwner {
        uint256 marketId = ++marketCount;
        uint256 endTime = block.timestamp + _durationFromNow;

        markets[marketId] = Market({
            description: _description,
            endTime: endTime,
            totalYesBets: 0,
            totalNoBets: 0,
            state: MarketState.Open,
            winner: Outcome.None
        });

        emit MarketCreated(marketId, _description, endTime);
    }

    /**
     * @notice 用户参与预测下注
     * @param _marketId 预测市场ID
     * @param _outcome 预测结果 (Yes=1, No=2)
     * @param _amount 下注代币数量
     */
    function placeBet(uint256 _marketId, Outcome _outcome, uint256 _amount) external nonReentrant {
        Market storage market = markets[_marketId];
        
        if (market.state != MarketState.Open) revert MarketNotOpen();
        if (block.timestamp >= market.endTime) revert BettingEnded();
        if (_outcome != Outcome.Yes && _outcome != Outcome.No) revert InvalidOutcome();
        if (_amount == 0) revert ZeroAmount();

        // 更新状态变量
        if (_outcome == Outcome.Yes) {
            market.totalYesBets += _amount;
        } else {
            market.totalNoBets += _amount;
        }
        userBets[_marketId][msg.sender][_outcome] += _amount;

        // 安全转移用户代币到本合约 (OZ V5 SafeERC20)
        bettingToken.safeTransferFrom(msg.sender, address(this), _amount);

        emit BetPlaced(_marketId, msg.sender, _outcome, _amount);
    }

    /**
     * @notice 由 AI 预言机/裁判(合约所有者)结算市场结果
     * @param _marketId 预测市场ID
     * @param _winner 获胜的结果 (Yes 或 No)
     */
    function resolveMarket(uint256 _marketId, Outcome _winner) external onlyOwner {
        Market storage market = markets[_marketId];
        
        if (market.state != MarketState.Open) revert MarketNotOpen();
        if (_winner != Outcome.Yes && _winner != Outcome.No) revert InvalidOutcome();

        market.state = MarketState.Resolved;
        market.winner = _winner;

        emit MarketResolved(_marketId, _winner);
    }

    /**
     * @notice 取消预测市场(用于应对突发不可抗力或无效预测)
     */
    function cancelMarket(uint256 _marketId) external onlyOwner {
        Market storage market = markets[_marketId];
        if (market.state != MarketState.Open) revert MarketNotOpen();

        market.state = MarketState.Cancelled;
        emit MarketCancelled(_marketId);
    }

    /**
     * @notice 预测成功者提取奖励(或市场取消后全额退款)
     * @param _marketId 预测市场ID
     */
    function claimReward(uint256 _marketId) external nonReentrant {
        Market storage market = markets[_marketId];
        uint256 payoutAmount = 0;

        if (market.state == MarketState.Resolved) {
            Outcome winner = market.winner;
            uint256 userWinningShares = userBets[_marketId][msg.sender][winner];
            if (userWinningShares == 0) revert NoWinningShares();

            uint256 totalWinningBets = (winner == Outcome.Yes) ? market.totalYesBets : market.totalNoBets;
            uint256 totalPool = market.totalYesBets + market.totalNoBets;

            // 完美的比例奖励计算:(用户持有的获胜额度 * 总资金池) / 总获胜额度
            payoutAmount = (userWinningShares * totalPool) / totalWinningBets;
            
            // 清零防止重复提款
            userBets[_marketId][msg.sender][winner] = 0;

        } else if (market.state == MarketState.Cancelled) {
            // 如果市场被取消,退还所有下注本金
            uint256 yesBet = userBets[_marketId][msg.sender][Outcome.Yes];
            uint256 noBet = userBets[_marketId][msg.sender][Outcome.No];
            payoutAmount = yesBet + noBet;
            if (payoutAmount == 0) revert ZeroAmount();

            userBets[_marketId][msg.sender][Outcome.Yes] = 0;
            userBets[_marketId][msg.sender][Outcome.No] = 0;
        } else {
            revert MarketNotResolved();
        }

        // 发送代币给用户
        bettingToken.safeTransfer(msg.sender, payoutAmount);
        emit RewardClaimed(_marketId, msg.sender, payoutAmount);
    }
}

四、 基于 Viem 与 Node 原生测试框架的集成测试

测试用例:

  • Rain Prediction Market Protocol Test Suite
    • 市场创建:管理员应能正确初始化预测市场并生成 ID
    • 下注功能:用户下注后应正确扣除资产,并更新全局资金池与个人额度
    • 结算领奖:市场产生胜者后,赢家应能按比例瓜分全部奖池
    • 异常拦截:非管理员无法创建或结算市场
    • 风控退款:市场被强制取消后,所有下注用户应能全额撤回本金
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseUnits, getAddress } from "viem";
import { network } from "hardhat";

describe("Rain Prediction Market Protocol Test Suite", function () {
  // 共享夹具:统一部署 MockToken 和 预测市场核心合约
  async function deployFixture() {
    const { viem } = await (network as any).connect();
    const [owner, alice, bob] = await viem.getWalletClients();
    const publicClient = await viem.getPublicClient();

    // 1. 部署模拟下注代币 (假设 6 位小数,模拟 USDC)
    const mockUSDC = await viem.deployContract("TestUSDT", ["TestUSDT", "USDT", 6]);

    // 2. 部署预测市场核心合约 (传入下注代币地址和初始管理员地址)
    const predictionMarket = await viem.deployContract("RainPredictionMarket", [
      mockUSDC.address,
      owner.account.address,
    ]);

    // 3. 为测试用户 Alice 和 Bob 分发初始代币,并完成对市场合约的额度授权
    const initialMint = parseUnits("1000", 6); // 1000 USDC
    await mockUSDC.write.mint([alice.account.address, initialMint]);
    await mockUSDC.write.mint([bob.account.address, initialMint]);

    // 用户授权市场合约扣款
    await mockUSDC.write.approve([predictionMarket.address, initialMint], { account: alice.account });
    await mockUSDC.write.approve([predictionMarket.address, initialMint], { account: bob.account });

    return {
      mockUSDC,
      predictionMarket,
      owner,
      alice,
      bob,
      publicClient,
    };
  }

  it("市场创建:管理员应能正确初始化预测市场并生成 ID", async function () {
    const { predictionMarket } = await deployFixture();

    const description = "Will ETH break $5000 in 2026?";
    const duration = 3600n; // 1小时后截止

    // 创建第一个市场
    await predictionMarket.write.createMarket([description, duration]);
    
    // 验证市场总数
    const marketCount = await predictionMarket.read.marketCount();
    assert.equal(marketCount, 1n, "市场计数器未正确累加");

    // 读取市场详情并验证
    const market = await predictionMarket.read.markets([1n]);
    assert.equal(market[0], description, "市场描述信息不匹配");
    assert.equal(market[4], 0, "新创建的市场状态应为 Open (0)");
  });

  it("下注功能:用户下注后应正确扣除资产,并更新全局资金池与个人额度", async function () {
    const { predictionMarket, mockUSDC, alice, bob } = await deployFixture();

    // 创建市场ID = 1
    await predictionMarket.write.createMarket(["Will Rain Token launch today?", 3600n]);

    const aliceBet = parseUnits("100", 6); // Alice 投 YES 100
    const bobBet = parseUnits("200", 6);   // Bob 投 NO 200

    // Alice 下注 YES (Outcome = 1)
    await predictionMarket.write.placeBet([1n, 1, aliceBet], { account: alice.account });
    // Bob 下注 NO (Outcome = 2)
    await predictionMarket.write.placeBet([1n, 2, bobBet], { account: bob.account });

    // 验证代币扣除
    const aliceBalance = await mockUSDC.read.balanceOf([alice.account.address]);
    assert.equal(aliceBalance, parseUnits("900", 6), "Alice 余额扣除不正确");

    // 验证合约内全局资金池
    const market = await predictionMarket.read.markets([1n]);
    assert.equal(market[2], aliceBet, "YES 资金池统计错误");
    assert.equal(market[3], bobBet, "NO 资金池统计错误");

    // 验证个人下注账本映射
    const aliceLedger = await predictionMarket.read.userBets([1n, alice.account.address, 1]);
    assert.equal(aliceLedger, aliceBet, "Alice 个人下注额账本记录错误");
  });

  it("结算领奖:市场产生胜者后,赢家应能按比例瓜分全部奖池", async function () {
    const { predictionMarket, mockUSDC, alice, bob } = await deployFixture();

    await predictionMarket.write.createMarket(["Will AI Replace Engineers?", 3600n]);

    // Alice 投入 100 USDC 支持 YES
    await predictionMarket.write.placeBet([1n, 1, parseUnits("100", 6)], { account: alice.account });
    // Bob 投入 300 USDC 支持 NO
    await predictionMarket.write.placeBet([1n, 2, parseUnits("300", 6)], { account: bob.account });

    // 管理员(AI 裁判)结算市场,判定结果为 YES 赢 (Outcome = 1)
    await predictionMarket.write.resolveMarket([1n, 1]);

    // 验证状态变为 Resolved (2) 且赢家为 YES (1)
    const market = await predictionMarket.read.markets([1n]);
    assert.equal(market[4], 2, "市场状态未更新为 Resolved");
    assert.equal(market[5], 1, "市场赢家结果判定错误");

    // Alice 作为唯一赢家领取奖励(理应独吞总池 100 + 300 = 400 USDC)
    const beforeClaim = await mockUSDC.read.balanceOf([alice.account.address]); // 900
    await predictionMarket.write.claimReward([1n], { account: alice.account });
    const afterClaim = await mockUSDC.read.balanceOf([alice.account.address]);

    assert.equal(afterClaim - beforeClaim, parseUnits("400", 6), "赢家未获得正确的全量分红奖励");
  });

  it("异常拦截:非管理员无法创建或结算市场", async function () {
    const { predictionMarket, alice } = await deployFixture();

    // 越权创建市场拦截
    await assert.rejects(
      async () => {
        await predictionMarket.write.createMarket(["Hack Market", 3600n], { account: alice.account });
      },
      /OwnableUnauthorizedAccount/,
      "非 Owner 应无权创建市场"
    );
  });

  it("风控退款:市场被强制取消后,所有下注用户应能全额撤回本金", async function () {
    const { predictionMarket, mockUSDC, alice, bob } = await deployFixture();

    await predictionMarket.write.createMarket(["Invalid Oracle Event", 3600n]);

    await predictionMarket.write.placeBet([1n, 1, parseUnits("150", 6)], { account: alice.account });
    await predictionMarket.write.placeBet([1n, 2, parseUnits("250", 6)], { account: bob.account });

    // 管理员触发风控取消市场
    await predictionMarket.write.cancelMarket([1n]);

    // Alice 申请退款
    await predictionMarket.write.claimReward([1n], { account: alice.account });
    const aliceBalance = await mockUSDC.read.balanceOf([alice.account.address]);
    
    // 回到初始 1000 USDC
    assert.equal(aliceBalance, parseUnits("1000", 6), "市场取消后用户本金未能全额退回");
  });
});

五、部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const TestUSDTArtifact = await artifacts.readArtifact("TestUSDT");
  const RainPredictionMarketArtifact = await artifacts.readArtifact("RainPredictionMarket");

  // 部署(构造函数参数:recipient, initialOwner)
  const TestUSDTArtifactHash = await deployer.deployContract({
    abi: TestUSDTArtifact.abi,//获取abi
    bytecode: TestUSDTArtifact.bytecode,//硬编码
    args: ["TestUSDT", "USDT", 6],//部署者地址,初始所有者地址
  });
   const TestUSDTReceipt = await publicClient.waitForTransactionReceipt({ hash: TestUSDTArtifactHash });
   console.log("USDT合约地址:", TestUSDTReceipt.contractAddress);
//
const RainPredictionMarketHash = await deployer.deployContract({
    abi: RainPredictionMarketArtifact.abi,//获取abi
    bytecode: RainPredictionMarketArtifact.bytecode,//硬编码
    args: [TestUSDTReceipt.contractAddress,deployerAddress],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const RainPredictionMarketReceipt = await publicClient.waitForTransactionReceipt({ hash: RainPredictionMarketHash });
  console.log("预测市场合约地址:", RainPredictionMarketReceipt.contractAddress);
}

main().catch(console.error);

总结

至此基于 Solidity 0.8.27 + OpenZeppelin V5 技术栈,实现了一个高性能的去中心化预测市场核心合约 RainPredictionMarket.sol,并配套完整的 Viem/Node 测试套件与部署脚本。

核心能力:

  • 市场创建:管理员可创建带截止时间的二元预测市场(YES/NO)
  • 多代币下注:用户通过 SafeERC20 安全投注,合约自动汇总资金池
  • AI 秒级结算:管理员(AI 预言机角色)可即时判定胜负,无需等待数小时的人类仲裁
  • 比例分红清算:赢家按投注占比瓜分总奖池,取消市场则全额退款
  • Gas 极致优化:全面采用 Custom Errors 替代字符串,适配 L2 高频场景

与 Polymarket 的本质差异:Polymarket 是面向 C 端的成品平台(绑定 USDC + UMA 人工仲裁),而 Rain Protocol 是面向 B 端的嵌入式基础设施(支持任意代币结算 + AI 自动化裁判)。

测试覆盖:市场创建、下注扣款、比例领奖、权限拦截、风控退款五大核心场景全部通过。

WebView 兼容性踩坑实录:那些让我加班的坑

WebView 兼容性踩坑实录:那些让我加班的坑

做了多年移动端H5开发,踩过的坑能绕地球一圈,今天盘点几个让我印象深刻的

前言

如果你是做移动端H5的,一定遇到过这种场景:

QA:这个页面在iOS上正常,Android上挂了
开发:什么Android机型?
QA:华为
开发:具体型号?
QA:不知道,就是华为
开发:...

或者:

用户:页面显示有问题
开发:什么手机?
用户:我就一破手机
开发:...

WebView的兼容性问题,每个都是坑。今天分享几个让我印象深刻(加班到深夜)的案例。


坑一:100vh 包含地址栏问题

问题现象

页面设置了 height: 100vh,在 iOS Safari 上正常,但在 Android Chrome 上:

  • 页面加载时,地址栏可见,100vh 包含地址栏高度
  • 用户向上滑动,地址栏隐藏,100vh 不变
  • 结果:页面底部多出一块空白

问题原因

iOS Safari 的 100vh 是视口高度,不包含地址栏。

Android Chrome 的 100vh 包含地址栏高度,但地址栏隐藏后不会重新计算。

解决方案

方案一:使用 dvh(推荐)

.container {
  height: 100vh; /* 兜底 */
  height: 100dvh; /* 动态视口高度,会随地址栏变化 */
}

但要注意:dvh 在 iOS 15.4+ 和 Android 108+ 才支持。

方案二:JS 计算

function setVH() {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
}

setVH();
window.addEventListener('resize', setVH);
.container {
  height: calc(var(--vh, 1vh) * 100);
}

如何用 WebView Inspector 排查

打开「兼容」Tab,检查 dvh 是否支持:

const compat = WebViewInspector.getCompat();
if (compat.css.dvh) {
  console.log('支持 dvh,可以直接使用');
} else {
  console.log('不支持 dvh,使用 JS 方案');
}

坑二:fixed 定位 + 软键盘

问题现象

页面底部有一个固定输入框:

.input-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
}

在 iOS 上:

  • 输入框聚焦,软键盘弹出
  • 输入框被键盘顶上去
  • 收起键盘后,输入框还在那个位置,不回到底部

在 Android 上:

  • 输入框聚焦,软键盘弹出
  • 输入框被键盘遮挡
  • 或者整个页面上移,布局乱掉

问题原因

iOS 和 Android 对软键盘的处理机制不同:

  • iOS:键盘弹出时,视口会调整,fixed 元素跟着动
  • Android:键盘弹出时,视口高度不变,fixed 元素位置不变

解决方案

方案一:监听 resize,动态调整

let originalHeight = window.innerHeight;

window.addEventListener('resize', () => {
  const currentHeight = window.innerHeight;
  
  if (currentHeight < originalHeight) {
    // 键盘弹出
    document.querySelector('.input-bar').style.position = 'absolute';
  } else {
    // 键盘收起
    document.querySelector('.input-bar').style.position = 'fixed';
  }
});

方案二:使用 visualViewport API

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', () => {
    const viewportHeight = window.visualViewport.height;
    document.querySelector('.input-bar').style.bottom = 
      `${window.innerHeight - viewportHeight}px`;
  });
}

如何用 WebView Inspector 排查

检查是否支持 visualViewport:

const compat = WebViewInspector.getCompat();
if (compat.js.visualViewport) {
  console.log('支持 visualViewport API');
}

坑三:iOS 安全区适配

问题现象

页面底部有按钮,在 iPhone X 及以上机型:

  • 按钮被 Home Indicator 遮挡
  • 或者按钮和屏幕边缘贴得太紧

问题原因

iPhone X 开始,屏幕底部有 Home Indicator 区域(约 34px),需要预留安全区。

解决方案

方案一:使用 env(safe-area-inset-bottom)

.button-bar {
  padding-bottom: env(safe-area-inset-bottom);
}

方案二:viewport 设置

<meta name="viewport" content="viewport-fit=cover">
.button-bar {
  padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0-11.2 */
  padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2+ */
}

如何用 WebView Inspector 排查

打开「环境」Tab,可以看到安全区尺寸:

const env = WebViewInspector.getEnv();
console.log('安全区底部:', env.safeAreaInsets.bottom);

坑四:微信 X5 内核的特殊行为

问题现象

在微信内置浏览器中:

  • 某些 CSS 特性不支持
  • 某些 JS API 行为异常
  • 页面渲染和 Chrome 不一致

问题原因

微信使用的是 X5 内核,基于旧版 Chromium,可能存在兼容性问题。

常见问题

  1. 不支持 ES Module

    // X5 内核可能不支持
    import xxx from './xxx.js';
    
  2. 不支持某些 CSS 特性

    /* X5 可能不支持 */
    .element {
      backdrop-filter: blur(10px);
      color: oklch(0.7 0.15 180);
    }
    
  3. localStorage 配额异常

    // X5 可能限制更严格
    localStorage.setItem('key', largeData);
    

解决方案

使用 WebView Inspector 检测 X5 内核支持的特性:

const env = WebViewInspector.getEnv();
const compat = WebViewInspector.getCompat();

if (env.webview.includes('X5')) {
  console.log('检测到 X5 内核');
  
  if (!compat.css.backdropFilter) {
    // 不支持 backdrop-filter,使用替代方案
    element.style.background = 'rgba(0,0,0,0.8)';
  }
}

坑五:Promise 未捕获错误静默失败

问题现象

// 这段代码在 PC 上会报错,但在某些 WebView 上静默失败
Promise.reject('错误');

用户反馈页面"卡住了",但控制台没有任何错误信息。

问题原因

某些 WebView 对未捕获的 Promise rejection 处理不一致:

  • 有的会触发 unhandledrejection 事件
  • 有的静默失败,不报错

解决方案

全局捕获 Promise 错误

window.addEventListener('unhandledrejection', (event) => {
  console.error('未捕获的 Promise 错误:', event.reason);
  
  // 上报错误
  reportError({
    type: 'PROMISE',
    message: event.reason,
    stack: event.reason?.stack,
    env: WebViewInspector.getEnv()
  });
});

WebView Inspector 已内置

WebView Inspector 会自动捕获 Promise 错误,打开「错误」Tab 即可看到。


坑六:IntersectionObserver 不触发

问题现象

const observer = new IntersectionObserver((entries) => {
  console.log('触发');
});
observer.observe(target);

在 PC 上正常触发,但在某些 WebView 上永远不触发。

问题原因

某些 WebView(特别是 iOS 12.2 以下)不支持 IntersectionObserver,或者行为异常。

解决方案

兼容性检测 + 降级方案

const compat = WebViewInspector.getCompat();

if (compat.js.IntersectionObserver) {
  // 使用 IntersectionObserver
  const observer = new IntersectionObserver(callback);
  observer.observe(target);
} else {
  // 降级:使用 scroll 事件
  window.addEventListener('scroll', () => {
    const rect = target.getBoundingClientRect();
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      callback();
    }
  });
}

排查方法论

踩了这么多坑,我总结了一套排查方法:

1. 获取环境信息

用户反馈问题时,先获取环境信息:

const env = WebViewInspector.getEnv();
console.log('设备:', env.device);
console.log('系统:', env.os);
console.log('WebView:', env.webview);

2. 检测特性支持

const compat = WebViewInspector.getCompat();
if (!compat.css.grid) {
  console.log('不支持 Grid');
}
if (!compat.js.IntersectionObserver) {
  console.log('不支持 IntersectionObserver');
}

3. 捕获错误

const errors = WebViewInspector.getErrors();
if (errors.length > 0) {
  // 有错误,分析错误信息
}

4. 一键复制报告

把环境信息 + 兼容性报告 + 错误信息一起复制,发给后端或记录到工单。


工具推荐

以上排查方法,都可以用 WebView Inspector 一键搞定:

  • 环境检测:设备、系统、WebView 类型/版本
  • 兼容性检测:30+ CSS 特性 + 56+ JS API
  • 错误捕获:JS 错误 + Promise 错误 + 资源错误

安装方式

npm install webview-inspector
import WebViewInspector from 'webview-inspector';
WebViewInspector.init();

相关链接


结语

WebView 兼容性是个无底洞,每个坑都能让你加班到深夜。

但有了正确的工具和方法,排查效率至少提升 10 倍。

你踩过哪些坑?评论区分享一下吧!


#WebView兼容性 #移动端H5 #前端踩坑 #WebView调试

你的前端滤镜慢得像PPT?用Rust+WebAssembly,一秒处理4K图

你给网页加了个“复古滤镜”功能,结果一拖动滑块,页面直接卡死。用户点一下,风扇狂转,手机发烫。今天我们用 Rust + WebAssembly 写一个图片滤镜,让图像处理速度飞起来。原来C++能做的事,Rust也能做,而且更安全、更简单。

前言

纯 JS 处理图像有多慢?假设你要把一张 4K 图片(约 8 百万像素)转成黑白,每个像素都要计算 R、G、B 的平均值。JS 需要遍历所有像素,做 2400 万次运算。这在现代设备上可能还要 100 毫秒,但一旦加上更复杂的滤镜(高斯模糊、边缘检测),帧率直接掉到个位数。

WebAssembly 的出现,让浏览器能以接近原生的速度执行代码。而 Rust 凭借零成本抽象和内存安全,成了写 Wasm 的首选语言。今天我们就来实战:用 Rust 写一个图像灰度滤镜,编译成 Wasm,然后在网页上让用户拖拽实时预览。全程可运行,不画饼。

一、为什么用 Rust 写 Wasm,而不是 C++?

  • 工具链友好wasm-pack 一键打包,自动生成 JS 胶水代码和 TypeScript 类型定义。
  • 内存安全:不用担心悬垂指针、缓冲区溢出,Rust 编译器帮你查。
  • 体积小:默认优化下,一个简单的滤镜函数可能只有几 KB。
  • 社区活跃:前端工具链(SWC、Biome)都用 Rust,生态会越来越好。

二、环境准备

你需要安装 Rust(curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh)和 wasm-packcargo install wasm-pack)。

创建一个新项目:

cargo new --lib image-filter
cd image-filter

编辑 Cargo.toml

[package]
name = "image-filter"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"

三、写一个灰度滤镜

src/lib.rs 中:

use wasm_bindgen::prelude::*;

// 将 Rust 函数暴露给 JS
#[wasm_bindgen]
pub fn grayscale(data: &mut [u8], width: u32, height: u32) {
    // data 是 RGBA 像素数组,每个像素 4 个字节:R, G, B, A
    for pixel in data.chunks_exact_mut(4) {
        let r = pixel[0] as u32;
        let g = pixel[1] as u32;
        let b = pixel[2] as u32;
        // 灰度公式:0.299*R + 0.587*G + 0.114*B
        let gray = ((r * 299 + g * 587 + b * 114) / 1000) as u8;
        pixel[0] = gray;
        pixel[1] = gray;
        pixel[2] = gray;
        // alpha 不变
    }
}

这个函数会直接修改原数组,没有内存拷贝,效率极高。

四、编译成 Wasm

wasm-pack build --target web

输出在 pkg/ 目录,包含 .wasm 文件、JS 绑定和 TypeScript 类型。

五、在网页中使用

创建一个 index.html

<!DOCTYPE html>
<html>
<head>
    <title>Rust Wasm 图像滤镜</title>
    <style>
        canvas { border: 1px solid #ccc; max-width: 100%; }
        .container { display: flex; gap: 20px; flex-wrap: wrap; }
        button { margin-top: 10px; padding: 8px 16px; }
    </style>
</head>
<body>
    <h1>Rust + WebAssembly 实时灰度滤镜</h1>
    <input type="file" id="upload" accept="image/*">
    <div class="container">
        <div>
            <canvas id="original" width="400" height="400"></canvas>
            <div>原图</div>
        </div>
        <div>
            <canvas id="filtered" width="400" height="400"></canvas>
            <div>灰度滤镜(Rust Wasm)</div>
        </div>
    </div>
    <button id="apply">应用滤镜</button>

    <script type="module">
        import init, { grayscale } from './pkg/image_filter.js';

        async function run() {
            await init(); // 加载 Wasm

            const upload = document.getElementById('upload');
            const originalCanvas = document.getElementById('original');
            const filteredCanvas = document.getElementById('filtered');
            const applyBtn = document.getElementById('apply');
            let originalImageData = null;

            upload.addEventListener('change', (e) => {
                const file = e.target.files[0];
                if (!file) return;
                const img = new Image();
                img.onload = () => {
                    // 绘制原图
                    originalCanvas.width = img.width;
                    originalCanvas.height = img.height;
                    filteredCanvas.width = img.width;
                    filteredCanvas.height = img.height;
                    const ctx = originalCanvas.getContext('2d');
                    ctx.drawImage(img, 0, 0);
                    originalImageData = ctx.getImageData(0, 0, img.width, img.height);
                    // 默认显示原图到右侧
                    applyFilter();
                };
                img.src = URL.createObjectURL(file);
            });

            function applyFilter() {
                if (!originalImageData) return;
                // 复制图像数据(避免修改原图)
                const dataCopy = new Uint8ClampedArray(originalImageData.data);
                const width = originalImageData.width;
                const height = originalImageData.height;
                // 调用 Rust 函数,直接修改 dataCopy
                grayscale(dataCopy, width, height);
                // 显示到右侧 canvas
                const imageData = new ImageData(dataCopy, width, height);
                const ctx = filteredCanvas.getContext('2d');
                ctx.putImageData(imageData, 0, 0);
            }

            applyBtn.addEventListener('click', applyFilter);
        }

        run();
    </script>
</body>
</html>

注意:pkg 目录需要在一个静态服务器下运行,比如 npx http-server。直接打开 HTML 会因 CORS 或跨域问题无法加载 Wasm。

六、效果实测

选择一个高分辨率图片,点击“应用滤镜”。你会发现几乎是瞬间完成——因为 Rust 循环编译成 Wasm 后,速度比纯 JS 循环快 5-10 倍。即使 4K 图片,也感觉不到延迟。

如果想对比 JS 版本,可以用同样的灰度算法写一个纯 JS 函数,你会明显感受到卡顿(尤其是拖动滑块实时调整时)。

七、进阶:让滑块实时预览

applyBtn 换成 range slider,监听 input 事件,每帧都调用 grayscale。由于 Wasm 够快,可以做到 60fps 实时调参。

const intensitySlider = document.getElementById('intensity');
intensitySlider.addEventListener('input', () => {
    const val = parseFloat(intensitySlider.value);
    // 将强度作为参数传给 Rust(需要修改 Rust 函数,增加亮度系数)
    // 略...
});

你甚至可以实现更复杂的滤镜(模糊、边缘检测、油画效果),只要把算法用 Rust 实现,其余和灰度类似。

八、生产环境注意事项

  • 内存共享:上面例子是把 Uint8ClampedArray 传给 Rust,wasm-bindgen 会自动共享内存,不需要拷贝。
  • 大图处理:尽量用 ImageBitmap 和 OffscreenCanvas,避免阻塞主线程。Wasm 本身是在主线程跑的,除非你用 Worker。
  • 体积优化wasm-pack build --release 可以大幅减小体积。还可以用 wasm-opt 进一步优化。
  • 浏览器兼容:所有现代桌面和移动浏览器都支持 Wasm。IE 已死,放心用。

九、总结:Rust + Wasm 是前端的“涡轮增压”

  • 计算密集型任务(图像处理、音视频编解码、物理模拟)用 Rust 写 Wasm,性能接近原生。
  • 开发体验好:wasm-pack 生成开箱即用的 JS 模块。
  • 适合替换现有 JS 中的性能瓶颈,而不是重写整个应用。

下次老板让你加个“实时滤镜”,别再写三重循环的 JS 了。用 Rust,一秒处理 4K 图,用户只会觉得你的网站“好丝滑”。

WebView 性能优化实战:从首屏1.5秒到300毫秒

WebView 性能优化实战:从首屏1.5秒到300毫秒

做移动端H5开发,性能是永恒的话题。本文分享我在实战中总结的WebView优化方案

前言

"页面加载太慢了,用户都流失了"

这是很多移动端H5开发者面临的痛点。WebView的性能直接影响用户体验,但优化起来往往无从下手。

本文从实战角度出发,分享我从首屏1.5秒优化到300毫秒的经验。


一、性能指标定义

1.1 核心指标

指标 说明 目标值
白屏时间 从发起请求到首屏可见 < 500ms
首屏时间 从发起请求到首屏内容渲染完成 < 1000ms
可交互时间 页面可以响应用户操作 < 1500ms
完全加载时间 所有资源加载完成 < 3000ms

1.2 测量方法

使用 Performance API

// 白屏时间
const whiteScreenTime = performance.timing.domLoading - performance.timing.navigationStart;

// 首屏时间
const firstPaintTime = performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;

// 完全加载时间
const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart;

使用 PerformanceObserver(推荐)

// 观察首屏渲染
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('FP:', entry.startTime); // First Paint
  }
});
observer.observe({ type: 'paint', buffered: true });

// 观察 LCP(Largest Contentful Paint)
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP:', lastEntry.startTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

二、WebView 初始化优化

2.1 WebView 预加载

问题:WebView 初始化需要时间,首次打开页面会有延迟。

方案:在 Application 启动时预热 WebView。

Android 实现

public class MyApp extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        
        // 在主线程预热 WebView
        WebView webView = new WebView(this);
        webView.destroy();
    }
}

iOS 实现

// 在 AppDelegate 中预热 WKWebView
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    // 预热 WKWebView
    let config = WKWebViewConfiguration()
    let webView = WKWebView(frame: .zero, configuration: config)
    
    return true
}

优化效果:首次打开 WebView 时间减少 200-300ms。

2.2 WebView 复用池

public class WebViewPool {
    private static final int MAX_POOL_SIZE = 2;
    private static final Queue<WebView> pool = new LinkedList<>();
    
    public static WebView obtain(Context context) {
        WebView webView = pool.poll();
        if (webView == null) {
            webView = new WebView(context);
        }
        return webView;
    }
    
    public static void recycle(WebView webView) {
        if (pool.size() < MAX_POOL_SIZE) {
            webView.loadUrl("about:blank");
            webView.clearCache(true);
            pool.offer(webView);
        } else {
            webView.destroy();
        }
    }
}

三、网络请求优化

3.1 DNS 预解析

<head>
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="//cdn.example.com">
  <link rel="dns-prefetch" href="//api.example.com">
</head>

3.2 预连接

<head>
  <!-- 预建立连接 -->
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="preconnect" href="https://api.example.com" crossorigin>
</head>

3.3 资源预加载

<head>
  <!-- 预加载关键资源 -->
  <link rel="preload" href="/css/main.css" as="style">
  <link rel="preload" href="/js/main.js" as="script">
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
</head>

3.4 HTTP 缓存策略

静态资源:强缓存 + 版本号

# nginx 配置
location ~* \.(js|css|png|jpg|gif|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

HTML 页面:协商缓存

location ~* \.html$ {
    etag on;
    if_modified_since exact;
    add_header Cache-Control "no-cache";
}

四、WebView 缓存优化

4.1 离线缓存方案

方案一:Application Cache(已废弃)

不推荐,现代浏览器已移除支持。

方案二:Service Worker(推荐)

// 注册 Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('SW registered'))
    .catch(error => console.log('SW registration failed'));
}

// sw.js
const CACHE_NAME = 'v1';
const CACHE_URLS = [
  '/',
  '/css/main.css',
  '/js/main.js'
];

// 安装时缓存
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(CACHE_URLS))
  );
});

// 拦截请求
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

4.2 Android WebView 缓存配置

WebSettings settings = webView.getSettings();

// 开启 DOM Storage
settings.setDomStorageEnabled(true);

// 开启数据库缓存
settings.setDatabaseEnabled(true);

// 设置缓存模式
settings.setCacheMode(WebSettings.LOAD_DEFAULT);

// 设置缓存路径
String cachePath = getCacheDir().getAbsolutePath();
settings.setAppCachePath(cachePath);
settings.setAppCacheEnabled(true);

4.3 iOS WKWebView 缓存配置

let config = WKWebViewConfiguration()

// 配置缓存
let websiteDataStore = WKWebsiteDataStore.nonPersistent()
config.websiteDataStore = websiteDataStore

// 或者使用默认缓存
config.websiteDataStore = .default()

五、渲染优化

5.1 阻塞渲染的资源处理

CSS 放头部,JS 放底部

<head>
  <link rel="stylesheet" href="/css/main.css">
</head>
<body>
  <!-- 内容 -->
  <script src="/js/main.js"></script>
</body>

JS 异步加载

<!-- 异步加载,不阻塞解析 -->
<script async src="/js/analytics.js"></script>

<!-- 延迟加载,解析完成后执行 -->
<script defer src="/js/main.js"></script>

5.2 关键渲染路径优化

内联关键 CSS

<head>
  <style>
    /* 内联首屏关键 CSS */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 50px; background: #fff; }
    .content { padding: 20px; }
  </style>
  <link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">
</head>

5.3 图片优化

懒加载

<img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy">
// Intersection Observer 懒加载
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

响应式图片

<picture>
  <source srcset="image-small.webp" media="(max-width: 600px)" type="image/webp">
  <source srcset="image-large.webp" media="(min-width: 601px)" type="image/webp">
  <img src="image.jpg" alt="fallback">
</picture>

六、JavaScript 优化

6.1 代码分割

// 动态导入
const module = await import('./heavy-module.js');

// React 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

6.2 长任务优化

// 使用 requestIdleCallback
requestIdleCallback(() => {
  // 执行低优先级任务
});

// 使用 Web Worker
const worker = new Worker('worker.js');
worker.postMessage({ type: 'heavy-computation', data: largeData });
worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

6.3 事件处理优化

// 节流
function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

// 防抖
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 应用
window.addEventListener('scroll', throttle(handleScroll, 100));
input.addEventListener('input', debounce(handleInput, 300));

七、WebView 与原生交互优化

7.1 减少通信次数

问题:每次 JSBridge 调用都有开销。

方案:批量传输数据。

// 差:多次调用
const userInfo = await bridge.call('getUserInfo');
const deviceId = await bridge.call('getDeviceId');
const appVersion = await bridge.call('getAppVersion');

// 好:一次调用
const data = await bridge.call('getInitData');
// data = { userInfo, deviceId, appVersion }

7.2 使用消息队列

// 消息队列
const messageQueue = [];

function flushQueue() {
  if (messageQueue.length === 0) return;
  
  const messages = [...messageQueue];
  messageQueue.length = 0;
  
  bridge.call('batchMessages', messages);
}

function enqueueMessage(msg) {
  messageQueue.push(msg);
  requestAnimationFrame(flushQueue);
}

八、内存优化

8.1 Android WebView 内存泄漏

问题:WebView 持有 Activity 引用导致内存泄漏。

方案:使用独立进程 + 动态销毁。

// AndroidManifest.xml
<activity
    android:name=".WebViewActivity"
    android:process=":webview" />

// Activity 销毁时
@Override
protected void onDestroy() {
    if (webView != null) {
        webView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null);
        webView.clearHistory();
        webView.destroy();
        webView = null;
    }
    super.onDestroy();
}

8.2 iOS WKWebView 内存管理

deinit {
    webView.stopLoading()
    webView.navigationDelegate = nil
    webView.uiDelegate = nil
}

8.3 JS 内存管理

// 及时清理定时器
const timer = setInterval(() => {}, 1000);
clearInterval(timer);

// 及时清理事件监听
const handler = () => {};
element.addEventListener('click', handler);
element.removeEventListener('click', handler);

// 避免闭包内存泄漏
function createHandler() {
  const largeData = new Array(10000);
  return () => {
    // 使用 largeData
  };
}
// 用完后置空
let handler = createHandler();
handler();
handler = null;

九、实战案例

9.1 优化前

指标 数值
白屏时间 800ms
首屏时间 1500ms
完全加载 3500ms

9.2 优化措施

措施 效果
WebView 预加载 -200ms
DNS 预解析 + 预连接 -150ms
资源预加载 -200ms
关键 CSS 内联 -100ms
图片懒加载 -300ms
JS 代码分割 -150ms
缓存优化 -100ms

9.3 优化后

指标 数值 提升
白屏时间 300ms -62.5%
首屏时间 700ms -53.3%
完全加载 1800ms -48.6%

十、总结

WebView 性能优化是一个系统工程,需要从多个维度入手:

  1. 初始化优化:WebView 预加载、复用池
  2. 网络优化:DNS 预解析、预连接、预加载、缓存策略
  3. 渲染优化:阻塞资源处理、关键路径优化、图片懒加载
  4. JS 优化:代码分割、长任务处理、事件优化
  5. 交互优化:减少通信次数、消息队列
  6. 内存优化:防止内存泄漏、及时清理资源

记住:性能优化没有银弹,要根据实际场景选择合适的方案。先用工具测量,找到瓶颈,再对症下药。


#WebView性能优化 #移动端H5 #前端优化 #性能调优

ShaderToy-山峦+蓝天+白云

知识点

  • 杂色
  • 栅格
  • 山峦
  • 阳光
  • 补光
  • 雾效
  • 蓝天
  • 白云

课前必备

用noise 绘制山峦的算法:wolfram详解山峦算法

课程内容

1.杂色。

image-20241106141239814

2.栅格:降低采样频率,将杂色变成栅格。

02

3.栅格平滑过度。

03

4.云彩:对模糊后的图案进行多次变换叠加。

04

5.云与山:根据云彩的灰度值做起伏,可以画出云与山。

白云

1-杂色

杂色的实现原理就是随机数。

// 坐标系缩放系数
#define PROJECTION_SCALE  1.

// 坐标系
vec2 Coord(in vec2 pos) {
  return PROJECTION_SCALE * 2. * (pos - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 随机数
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 杂色
float Noise(vec2 pos){
  return Random(pos);
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  // float noise=Noise(coord);
  // float noise=Noise(coord*10.);
  float noise=Noise(coord*100.);
  fragColor=vec4(vec3(noise),1.);
}

效果如下:

08

现在就是一个毫无章法的杂色效果。

2-栅格

我们可以将点位取整,从而画出大块的杂色。

float Noise(vec2 pos){
  vec2 i=floor(pos);
  return Random(i);
}

效果如下:

image-20250523155540077

我们可以将coord*5.,使色块更大。

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  float noise=Noise(coord*5.);
  fragColor=vec4(vec3(noise),1.);
}

效果如下:

09

3-山峦的平滑过度

山峦的平滑过度的原理在wolfram详解山峦算法中有详解。

公式如下:

image-20241127151550726

e=a+(b-a)*fx*(1-fy)+(c-a)*fy*(1-fx)+(d-a)*fx*fy

代码如下:

// 坐标系缩放系数
#define PROJECTION_SCALE  1.

// 坐标系
vec2 Coord(in vec2 pos) {
  return PROJECTION_SCALE * 2. * (pos - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 随机数
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 杂色
float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*f.x*(1.-f.y)+(c-a)*f.y*(1.-f.x)+(d-a)*f.x*f.y;
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  float noise=Noise(coord*5.);
  fragColor=vec4(vec3(noise),1.);
}

效果如下:

14

4-山峦透视图

image-20260221100929091

山峦透视图涉及以下知识点:

  • RayMarching 光线推进
  • 山峦的SDF模型
  • 山峦的法线计算
  • 根据法线和平行光计算山峦颜色

整体代码如下:

// 坐标系缩放
#define PROJECTION_SCALE  1.
// 相机视点位
#define CAMERA_POS vec3(12,12,12)
// 相机目标点
#define CAMERA_TARGET vec3(0,0,0)
// 上方向
#define CAMERA_UP vec3(0, 1, 0)
// 光线推进的最远距离
#define RAYMARCH_FAR 200.
// 光线推进次数
#define RAYMARCH_NUM 512
// 光线推进精度,当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001

// RayMarch 数据的结构体
struct RayMarchData {
   // 射线碰撞到的着色点位置
  vec3 ro;
  // 推进距离
  float t;
  // 推进方向
  vec3 rd;
};

// 坐标系
vec2 Coord(in vec2 fragColor) {
  return PROJECTION_SCALE * 2. * (fragColor - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 视图旋转矩阵
mat3 RotateMatrix() {
  //基向量c,视线
  vec3 c = normalize(CAMERA_TARGET-CAMERA_POS );
  //基向量a,视线和上方向的垂线
  vec3 a = cross(c, CAMERA_UP);
  //基向量b,修正上方向
  vec3 b = cross(a, c);
  //正交旋转矩阵
  return mat3(a, b, c);
}

// 二维随机
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 三维平滑噪波
float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*f.x*(1.-f.y)+(c-a)*f.y*(1.-f.x)+(d-a)*f.x*f.y;
}

// 山峦生成函数
float Mount(vec2 p){
  return 2.* Noise(p);
}

// 山峦法线
vec3 MountNormal(vec3 p,float t){
  // 极小值,受到推进距离t的加权
  float epsilon=0.001*t;
  // 采样
  vec2 offsetX1=p.xz-vec2(epsilon,0);
  vec2 offsetX2=p.xz+vec2(epsilon,0);
  vec2 offsetZ1=p.xz-vec2(0,epsilon);
  vec2 offsetZ2=p.xz+vec2(0,epsilon);
  // 法线
  return normalize(vec3(
    Mount(offsetX1)-Mount(offsetX2),
    2.0*epsilon,
    Mount(offsetZ1)-Mount(offsetZ2)
  ));
}

// 山峦SDF
float MountSDF(vec3 pos){
  return pos.y-Mount(pos.xz);
}

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  // 光线推进数据
  RayMarchData rm = RayMarchData(vec3(0),0.,rd);
  float t=0.;
  for(int i=0;i<RAYMARCH_NUM;i++){
    vec3 p=ro+t*rd;
    float h=MountSDF(p);
    rm.ro=p;
    // RAYMARCH_PRECISION 受到推进距离t的加权
    if(abs(h)<RAYMARCH_PRECISION*t||t>RAYMARCH_FAR){
      break;
    }
    // 缩小推进距离
    t+=0.1*h;
  }
  rm.t=t;
  return rm;
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountNormal(rm.ro,rm.t);
    color=sqrt(vec3(dot(vec3(0,1,0),n)));
  }
  return color;
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  vec3 col=render(coord);
  fragColor=vec4(col,1);
}

详细解释一下上面的代码。

RayMarching 光线推进

1.定义相机。

根据相机的视点、目标点和上方向可以计算视图旋转矩阵。

代码如下:

// 相机视点位
#define CAMERA_POS vec3(12,12,12)
// 相机目标点
#define CAMERA_TARGET vec3(0,0,0)
// 上方向
#define CAMERA_UP vec3(0, 1, 0)
    
// 视图旋转矩阵
mat3 RotateMatrix() {
  //基向量c,视线
  vec3 c = normalize(CAMERA_TARGET-CAMERA_POS );
  //基向量a,视线和上方向的垂线
  vec3 a = cross(c, CAMERA_UP);
  //基向量b,修正上方向
  vec3 b = cross(a, c);
  //正交旋转矩阵
  return mat3(a, b, c);
}

2.将fragCoord 坐标转换为一种原点在屏幕中心的屏幕坐标。

// 坐标系缩放
#define PROJECTION_SCALE  1.
// 坐标系
vec2 Coord(in vec2 fragColor) {
  return PROJECTION_SCALE * 2. * (fragColor - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}
void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  //...
}

3.将相机的初始位置定义为(0,0,-2),则从相机向栅格图像中的每个栅格推进的初始方向就是vec3(coord,2.)

4.用相机的视图旋转矩阵旋转初始推进方向,便可得到世界坐标系中的推进方向。

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向  
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  //...
}

5.RayMarchData 是自定义的光线推进数据的结构体,以便于数据管理。

// RayMarch 数据的结构体
struct RayMarchData {
   // 射线碰撞到的着色点位置
  vec3 ro;
  // 推进距离
  float t;
  // 推进方向
  vec3 rd;
};

山峦SDF模型

山峦SDF模型的距离判断原理:在每次光线推进时,计算推进点的高度位置到其正下方的山峦距离。

1.根据推进点的x、z 值,可以算出相应位置的山峦高度。

代码如下:

// 二维随机
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 三维平滑噪波
float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*f.x*(1.-f.y)+(c-a)*f.y*(1.-f.x)+(d-a)*f.x*f.y;
}

// 山峦生成函数
float Mount(vec2 p){
  return 2.* Noise(p);
}

Mount(vec2 p) 函数提升了山体的高度。

2.计算山峦高度到推进点的y 值的距离,若距离小于某个精度值,便认为射线碰到了山体。

否则,根据此距离推进光线。

代码如下:

// 山峦SDF
float MountSDF(vec3 pos){
  return pos.y-Mount(pos.xz);
}

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  // 光线推进数据
  RayMarchData rm = RayMarchData(vec3(0),0.,rd);
  float t=0.;
  for(int i=0;i<RAYMARCH_NUM;i++){
    vec3 p=ro+t*rd;
    float h=MountSDF(p);
    rm.ro=p;
    // RAYMARCH_PRECISION*t:近处精度高,远处精度低
    if(abs(h)<RAYMARCH_PRECISION*t||t>RAYMARCH_FAR){
      break;
    }
    // 缩小推进距离
    t+=0.1*h;
  }
  rm.t=t;
  return rm;
}

当然,这种光线推进的方法并不是绝对严谨的,因为这距离并不是推进点到山体的垂直距离,但其优点是计算快捷。

为了尽量避免距离误差,我们将推进距离缩小。

t+=0.1*h;

山峦的法线计算

山峦的法线可以用于逐片元计算受光程度。

山峦法线的计算原理是:以当前片元为中心,在一个极小范围内计算法线,假设此极小范围是一个平面。

算法示例

假设y=2x+3 是山峦函数,求它x=1处的法线。

image-20260318165526677

理解斜截式的同学肯定能看出它的斜率跟3没关系,且任意位置的法线都是一样的。

它在任意位置的法线都是y=2x上任一非零的点旋转90°的归一化。

如(1,2)旋转90°后的(-2,1)的归一化,即(−0.894, 0.447)。

image-20260318170032428

假设我们不知道斜截式的规律,我们可以换个思路计算其法线。

设精度为0.001。

取x=(1-0.001)和x=(1+0.001)处的y值,即:

y1=2*(1-0.001)+3=4.998
y2=2*(1+0.001)+3=5.002

则x=1处的法线为:

normalize(y1-y2,2*0.001)=normalize(-0.004,0.002)=(−0.894, 0.447)

其原理就是取任意不重合的两点,算一下其相对位置,然后旋转90°,做归一化。

代码实现

山峦法线的代码实现就是以极小范围采样的方式,将上面求二维直线的法线变成求三维平面的法线。

vec3 MountNormal(vec3 p,float t){
  // 极小值,受到推进距离t的加权
  float epsilon=0.001*t;
  // 采样
  vec2 offsetX1=p.xz-vec2(epsilon,0);
  vec2 offsetX2=p.xz+vec2(epsilon,0);
  vec2 offsetZ1=p.xz-vec2(0,epsilon);
  vec2 offsetZ2=p.xz+vec2(0,epsilon);
  // 法线
  return normalize(vec3(
    Mount(offsetX1)-Mount(offsetX2),
    2.0*epsilon,
    Mount(offsetZ1)-Mount(offsetZ2)
  ));
}

大家可以调整epsilon 的大小,观察采样精度对渲染效果的影响。

根据法线和平行光计算山峦颜色

将光线方向与法线做点积运算,便可以得到片元的受光程度。

代码如下:

vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountNormal(rm.ro,rm.t);
    color=sqrt(vec3(dot(vec3(0,1,0),n)));
  }
  return color;
}

在当前的代码里,我们使用的平行光,光线是从正上方打下来的。

sqrt 可以加强山峦的颜色的对比度。

效果分析

image-20260221100929091

通过当前的山峦效果,我们可以看到以下问题:

  • 山体形状太板
  • 缺少细节层次

接下来我们就解决这些问题。

5-山峦圆滑

山峦的圆滑的原理在wolfram详解山峦算法中有详解。

其基本原理就是将山峦平滑过度时的线性补间变成曲线补间。

山峦圆滑

圆滑代码如下:

float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  vec2 u=3.*f*f-2.*f*f*f;
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*u.x*(1.-u.y)+(c-a)*(1.-u.x)*u.y+(d-a)*u.x*u.y;
}

其它代码不变。

现在的山峦还有些单薄,我们对其进行多次变换叠加。

6-山峦叠加

山峦的叠加的原理在wolfram详解山峦算法中有详解。

山峦叠加

相关代码如下:

// 三维噪波
vec3 Noise(vec2 p){
  vec2 i=floor(p);
  vec2 f=fract(p);
  vec2 u=3.*f*f-2.*f*f*f;
  // u的偏导函数
  vec2 du=6.*f-6.*f*f;
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  // 让半山腰的山凹下去
  float x=a+(b-a)*u.x*(1.-u.y)+(c-a)*(1.-u.x)*u.y+(d-a)*u.x*u.y;
  //x的偏导函数
  vec2 yz=du*(vec2(b-a,c-a)+(a-b-c+d)*u.yx);
  return vec3(x,yz);
}

// 山体变换矩阵
// 2可使山体每次迭代后更密集,增加山体细节;旋转矩阵可使山体更加随机、自然
mat2 mountainTF=2.*mat2(0.6,-0.8,0.8,0.6);
// 山峦生成器
float MountainFn(vec2 p,int len){
  // 山峰高度
  float a=0.;
  // 山峰高度的增量,每次迭代后增量减半,使得山峰越高,增量越小
  float b=1.;
  // 山峰斜率的累积,越陡峭,斜率越大,增量越小。即山峰陡峭处更平滑。
  vec2 d=vec2(0);
  for(int i=0;i<len;i++){
    // 1.65可使山体更高; p*0.5可使山体更平缓
    vec3 n=1.65*Noise(p*0.5);
    a+=b*n.x/(1.+dot(d,d));
    // 累积斜率
    d+=n.yz;
    // 变换采样点,使得山体更自然
    p=mountainTF*p;
    // 减小山峰高度的增量
    b*=0.56;
  }
  return a;
}
// 法线贴图
float MountainNormalFn(vec2 p){
  return MountainFn(p,12);
}
// 山峦
float Mountain(vec2 p){
  return MountainFn(p,6);
}
// 计算法线
vec3 MountainNormal(vec3 p,float t){
  // 一种极小值
  vec2 epsilon=vec2(0.001*t,0);
  // 法线
  return normalize(vec3(
    MountainNormalFn(p.xz-epsilon.xy)-MountainNormalFn(p.xz+epsilon.xy),
    2.0*epsilon.x,
    MountainNormalFn(p.xz-epsilon.yx)-MountainNormalFn(p.xz+epsilon.yx)
  ));
}

山峦模型Mountain 和山峦法线MountainNormal 用了2种计算精度,这样可以在渲染效果和速度之间找一个平衡。

mountainTF 是缩放旋转矩阵,对每次叠加山峦进行变换,使之山峦更加自然。

Noise 中返回的yz数据是山峦的梯度,通过梯度可以确定山势的陡峭度,从而在山峰陡峭的地方,让叠加的山峦矮一些,从而更符合山峦的自然规律。

7-阳光

阳光可以理解为平行光,所以定义一个光线方向,打出投影既可。

阳光

相关代码如下:

// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.
// 光线推进的最远距离
#define RAYMARCH_FAR 200.
// 阳光方向
#define SUNLIGHT_DIRECTION normalize(vec3(0.5, 0.2, 0.2))

// 软阴影
float SoftShadow(in vec3 ro, in vec3 rd, float k) {
  float res = 1.;
  for(float t = RAYMARCH_NEAR; t < RAYMARCH_FAR;) {
    float h = MountainSDF(ro + rd * t);
    if(h < RAYMARCH_PRECISION) {
      return 0.;
    }
    res = min(res, k * h / t);
    t += h;
  }
  return res;
}
// 阳光
void AddSunLight(out vec3 color,vec3 p,float t,vec3 n) {
  // 阳光照明
  float l=max(dot(SUNLIGHT_DIRECTION, n), 0.);
  vec3 color1=vec3(0.02, 0.04, 0.11);
  vec3 color2=vec3(1.0);
  color=mix(color1,color2,l);
  // 阳光投影
  float f = SoftShadow(p, SUNLIGHT_DIRECTION, 8.);
  vec3 color3=vec3(0.05, 0.0, 0.09);
  vec3 color4=vec3(0.99, 0.96, 1.0);
  vec3 shadow=mix(color3,color4,f);
  color *= shadow;
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    color=basicColor;
  }
  return color;
}

8-补光

当前山体的投影是纯黑的,我们需要给它补光。

补光的方法有很多,最常见的环境光,但我这里图省事,就给了个垂直于地面的平行光。

相关代码如下:

// 环境光
void AddEnvironmentLight(out vec3 color,vec3 n){
  float l=sqrt(max(dot(vec3(0,1,0), n), 0.));
  vec3 color1=vec3(0.29, 0.32, 0.36);
  vec3 color2=vec3(1.0);
  vec3 ambientColor=mix(color1,color2,l);
  color=mix(color,ambientColor,0.3);
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    color=basicColor;
  }
  return color;
}

效果如下:

环境光

把相机镜头降低,你会发现山峦远处有噪波:

镜头降低

我们可以使用雾效掩盖此问题。

9-雾效

雾效效果:离视点越近越清晰,越远越接近雾色。

雾效

相关代码如下:

// 相机视点位
#define CAMERA_POS vec3(6.55,2.4,12.02)
// 相机目标点
#define CAMERA_TARGET vec3(6.5,1,0)
// 雾效近端距离
#define FOG_NEAR 20.
// 雾效远端距离
#define FOG_FAR FOG_NEAR+10.

// 雾效
void AddFog(out vec3 color,vec3 fogColor,float t){
  color=mix(color,fogColor,smoothstep(FOG_NEAR,FOG_FAR,t));
}
// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

当前的远山并没有给一个单纯的白色,而是使其更接近底色,即color=vec3(0)。

接下来我会给底色color一个蓝天的颜色,当远山接近这个颜色的时候,也可以理解为接近了雾色。

10-蓝天

蓝天的颜色是有渐变的,顶部更蓝,远方更白。

蓝天

相关代码如下:

// 天空
vec3 Sky(vec3 rd){
  // 基色
  vec3 basicColor=vec3(0.3, 0.5, 0.85);
  // 渐变色
  vec3 gradientColor = basicColor - rd.y * rd.y * 0.8;
  // 雾白
  vec3 fog=vec3(1);
  // 背景mix值
  float backMountainMix=pow(1.0 - max(rd.y, 0.0), 4.0);
  // 背景色
  vec3 backMountainColor = mix(gradientColor, fog, backMountainMix);
  return backMountainColor;
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=Sky(rm.rd);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

天空的渐变插值就是RayMarch的光线推进方向rd 的y 值。

11-太阳

太阳在阳光洒下的方向,要从山的背面才能看见。

太阳

在天空的绘制方法Sky 中画一个太阳。

// 天空
vec3 Sky(vec3 rd){
  // ...
  // 推进方向与太阳光线方向的点积
  float sunDot = clamp(dot(rd, SUNLIGHT_DIRECTION), 0.0, 1.0);
  float sun=pow(sunDot,4096.)*0.5;
  
  return backMountainColor+sun;
}

太阳的位置可以用RayMarch 方向与阳光方向的点积确定。

12-白云

白云依旧可以用山峦的noise 算法绘制。

白云

相关代码如下:

// 云彩高度
#define CLOUD_HEIGHT 100.

// 白云
mat2 cloudMatrix=mat2(0.6,-0.8,0.8,0.6);
float FractalBrownianNoise(vec2 p){
  float a=0.;
  float fac=1.0;
  float max=fac;
  for(int i=0;i<4;i++){
    a+=fac*Noise(p*0.015).x;
    max+=fac;
    p=2.*cloudMatrix*p;
    fac*=0.5;
  }
  float n=smoothstep(0.5,max,a);
  return n*3.;
}
void AddCloud(out vec3 color,RayMarchData rm){
  vec3 ro=rm.ro;
  vec3 rd=rm.rd;
  vec3 cloudUV=ro+(CLOUD_HEIGHT-ro.y)/rd.y*rd;
  float f=FractalBrownianNoise(cloudUV.xz);
  color=mix(color,vec3(1,0.95,1),f);
}

vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=Sky(rm.rd);
  AddCloud(color,rm);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

这种会绘制云彩noise 有个专门的名称叫Fractal Brownian Noise。

云彩着色点的位置cloudUV 是从rayMarch 的原点ro 推导的。

image-20260505120811048

已知:

  • h 是云彩高度
  • ro 是rayMarch 的原点
  • rd 是rayMarch 的方向

求:rayMarch 推进到云彩上的位置P

解:

P=ro+((h-ro.y)/rd.y)*rd

13-整体代码

整体代码如下:

// 坐标系缩放
#define PROJECTION_SCALE  1.
// 相机视点位
#define CAMERA_POS vec3(6.55,2.4,12.02)
// 相机目标点
#define CAMERA_TARGET vec3(6.5,1,0.)
// 上方向
#define CAMERA_UP vec3(0, 1, 0)
// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.
// 光线推进的最远距离
#define RAYMARCH_FAR 200.
// 光线推进次数
#define RAYMARCH_NUM 512
// 光线推进精度,当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001
// 阳光方向
#define SUNLIGHT_DIRECTION normalize(vec3(0.5, 0.2, 0.2))
// 雾效近端距离
#define FOG_NEAR 20.
// 雾效远端距离
#define FOG_FAR FOG_NEAR+10.
// 云彩高度
#define CLOUD_HEIGHT 100.

// RayMarch 数据的结构体
struct RayMarchData {
   // 射线碰撞到的着色点位置
  vec3 ro;
  // 推进距离
  float t;
  // 推进方向
  vec3 rd;
};

// 坐标系
vec2 Coord(in vec2 fragColor) {
  return PROJECTION_SCALE * 2. * (fragColor - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 视图旋转矩阵
mat3 RotateMatrix() {
  //基向量c,视线
  vec3 c = normalize(CAMERA_TARGET-CAMERA_POS );
  //基向量a,视线和上方向的垂线
  vec3 a = cross(c, CAMERA_UP);
  //基向量b,修正上方向
  vec3 b = cross(a, c);
  //正交旋转矩阵
  return mat3(a, b, c);
}

// 二维随机
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 三维噪波
vec3 Noise(vec2 p){
  vec2 i=floor(p);
  vec2 f=fract(p);
  vec2 u=3.*f*f-2.*f*f*f;
  // u的偏导函数
  vec2 du=6.*f-6.*f*f;
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  // 让半山腰的山凹下去
  float x=a+(b-a)*u.x*(1.-u.y)+(c-a)*(1.-u.x)*u.y+(d-a)*u.x*u.y;
  //x的偏导函数
  vec2 yz=du*(vec2(b-a,c-a)+(a-b-c+d)*u.yx);
  return vec3(x,yz);
}

// 山体变换矩阵
// 2可使山体每次迭代后更密集,增加山体细节;旋转矩阵可使山体更加随机、自然
mat2 mountainTF=2.*mat2(0.6,-0.8,0.8,0.6);
// 山峦生成器
float MountainFn(vec2 p,int len){
  // 山峰高度
  float a=0.;
  // 山峰高度的增量,每次迭代后增量减半,使得山峰越高,增量越小
  float b=1.;
  // 山峰斜率的累积,越陡峭,斜率越大,增量越小。即山峰陡峭处更平滑。
  vec2 d=vec2(0);
  for(int i=0;i<len;i++){
    // 1.65可使山体更高; p*0.5可使山体更平缓
    vec3 n=1.65*Noise(p*0.5);
    a+=b*n.x/(1.+dot(d,d));
    // 累积斜率
    d+=n.yz;
    // 变换采样点,使得山体更自然
    p=mountainTF*p;
    // 减小山峰高度的增量
    b*=0.56;
  }
  return a;
}
// 法线贴图
float MountainNormalFn(vec2 p){
  return MountainFn(p,12);
}
// 山峦
float Mountain(vec2 p){
  return MountainFn(p,6);
}
// 计算法线
vec3 MountainNormal(vec3 p,float t){
  // 一种极小值
  vec2 epsilon=vec2(0.001*t,0);
  // 法线
  return normalize(vec3(
    MountainNormalFn(p.xz-epsilon.xy)-MountainNormalFn(p.xz+epsilon.xy),
    2.0*epsilon.x,
    MountainNormalFn(p.xz-epsilon.yx)-MountainNormalFn(p.xz+epsilon.yx)
  ));
}

// 山峦SDF
float MountainSDF(vec3 pos){
  return pos.y-Mountain(pos.xz);
}

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  // 光线推进数据
  RayMarchData rm = RayMarchData(vec3(0),0.,rd);
  float t=0.;
  for(int i=0;i<RAYMARCH_NUM;i++){
    vec3 p=ro+t*rd;
    float h=MountainSDF(p);
    rm.ro=p;
    // RAYMARCH_PRECISION 受到推进距离t的加权
    if(abs(h)<RAYMARCH_PRECISION*t||t>RAYMARCH_FAR){
      break;
    }
    // 缩小推进距离
    t+=0.1*h;
  }
  rm.t=t;
  return rm;
}

// 软阴影
float SoftShadow(in vec3 ro, in vec3 rd, float k) {
  float res = 1.;
  for(float t = RAYMARCH_NEAR; t < RAYMARCH_FAR;) {
    float h = MountainSDF(ro + rd * t);
    if(h < RAYMARCH_PRECISION) {
      return 0.;
    }
    res = min(res, k * h / t);
    t += h;
  }
  return res;
}
// 阳光
void AddSunLight(out vec3 color,vec3 p,float t,vec3 n) {
  // 阳光照明
  float l=max(dot(SUNLIGHT_DIRECTION, n), 0.);
  vec3 color1=vec3(0.02, 0.04, 0.11);
  vec3 color2=vec3(1.0);
  color=mix(color1,color2,l);
  // 阳光投影
  float f = SoftShadow(p, SUNLIGHT_DIRECTION, 8.);
  vec3 color3=vec3(0.05, 0.0, 0.09);
  vec3 color4=vec3(0.99, 0.96, 1.0);
  vec3 shadow=mix(color3,color4,f);
  color *= shadow;
}
// 环境光
void AddEnvironmentLight(out vec3 color,vec3 n){
  float l=sqrt(max(dot(vec3(0,1,0), n), 0.));
  vec3 color1=vec3(0.29, 0.32, 0.36);
  vec3 color2=vec3(1.0);
  vec3 ambientColor=mix(color1,color2,l);
  color=mix(color,ambientColor,0.3);
}
// 雾效
void AddFog(out vec3 color,vec3 fogColor,float t){
  color=mix(color,fogColor,smoothstep(FOG_NEAR,FOG_FAR,t));
}
// 天空
vec3 Sky(vec3 rd){
  // 基色
  vec3 basicColor=vec3(0.3, 0.5, 0.85);
  // 渐变色
  // rd.y∈[-1,1],rd.y * rd.y∈[0,1],rd.y * rd.y * 0.8∈[0,0.8]
  vec3 gradientColor = basicColor - rd.y * rd.y * 0.8;
  // 雾白
  vec3 fog=vec3(1);
  // 背景mix值
  float backMountainMix=pow(1.0 - max(rd.y, 0.0), 4.0);
  // 背景色
  vec3 backMountainColor = mix(gradientColor, fog, backMountainMix);
  
  // 推进方向与太阳光线方向的点积
  float sunDot = clamp(dot(rd, SUNLIGHT_DIRECTION), 0.0, 1.0);
  float sun=pow(sunDot,4096.)*0.5;
  
  return backMountainColor+sun;
}
// 白云
mat2 cloudMatrix=mat2(0.6,-0.8,0.8,0.6);
float FractalBrownianNoise(vec2 p){
  float a=0.;
  float fac=1.0;
  float max=fac;
  for(int i=0;i<4;i++){
    a+=fac*Noise(p*0.03).x;
    max+=fac;
    p=2.*cloudMatrix*p;
    fac*=0.5;
  }
  float n=smoothstep(0.5,max,a);
  return n*2.2;
}
void AddCloud(out vec3 color,RayMarchData rm){
  vec3 ro=rm.ro;
  vec3 rd=rm.rd;
  vec3 cloudUV=ro+(CLOUD_HEIGHT-ro.y)/rd.y*rd;
  float f=FractalBrownianNoise(cloudUV.xz);
  color=mix(color,vec3(1,0.95,1),f);
}
// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=Sky(rm.rd);
  AddCloud(color,rm);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  vec3 col=render(coord);
  fragColor=vec4(col,1);, 
}

总结

这一章我们说了杂色、栅格、山峦、阳光、补光、蓝天和白云的绘制,我们把更多的关注点都放在了山峦的形状上,而渲染效果并不是太真实。

后面我会再重点研究基于PBR的渲染。

参考链接:www.bilibili.com/video/BV18P…

WebAssembly:前端界的“外挂”,让C++代码在浏览器里跑起来

你的网页有个计算密集的任务(比如视频转码、图像滤镜、物理模拟),用JS写慢得像乌龟。你想:“要是能用C++写,然后在浏览器里跑就好了。” 今天的主角 WebAssembly 就是干这个的——它让你把C++、Rust等语言编译成一种近乎二进制的格式(.wasm),让浏览器以接近原生的速度执行。前端从此不只是JS的天下。

前言

JS 是解释型语言,哪怕 V8 再快,在处理大量计算时还是力不从心。而 WebAssembly(简称 Wasm)是一种低级的汇编式语言,浏览器可以极快地解析和执行。它不是要取代 JS,而是作为 JS 的“高性能搭档”:你用 JS 写业务逻辑,用 Wasm 写计算密集型模块。

今天我们就从零了解 Wasm:它是什么鬼?怎么用?性能真的翻倍吗?以及一个最经典的例子——用 C++ 写一个斐波那契数列,编译成 Wasm,然后在浏览器里调用。

一、WebAssembly 到底是什么?

你可以把它理解为一种中间码。你用 C++、Rust、Go 等语言写代码,然后编译成 .wasm 文件。浏览器下载这个文件,实例化后,JS 就可以调用其中的函数。

它和 JS 的区别:

  • JS:文本格式,需要解析、JIT 编译,性能好但不够稳定。
  • Wasm:二进制格式,体积小,解码快,执行效率接近原生(比 JS 快 1-10 倍,视任务而定)。

注意:Wasm 不能直接操作 DOM、调用浏览器 API,它只能做纯计算。它需要通过 JS 来输入数据、接收结果,并让 JS 更新界面。

二、为什么需要 Wasm?一个例子让你秒懂

假设你要对一张 4K 图片做高斯模糊。纯 JS 实现,需要遍历每个像素,三层循环,可能卡死浏览器。用 C++ 写同样的算法,编译成 Wasm,速度可能提升 5-10 倍。这就是 Wasm 的价值:把计算密集型任务交给“专业选手”

适用场景:

  • 视频/音频编解码
  • 图像处理(滤镜、识别)
  • 物理模拟(游戏、数据可视化)
  • 加密算法
  • 大型数学计算(如金融建模)

三、上手:把 C++ 编译成 Wasm

我们需要一个工具链:Emscripten。它能把 C/C++ 编译成 Wasm,并生成 JS 胶水代码。

1. 安装 Emscripten

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh  # 设置环境变量

2. 写一个简单的 C++ 函数

add.cpp:

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
  return a + b;
}

EMSCRIPTEN_KEEPALIVE 告诉编译器不要优化掉这个函数(否则会被 tree-shaking 移除)。

3. 编译成 Wasm

emcc add.cpp -o add.js -s WASM=1 -s EXPORTED_FUNCTIONS='["_add"]' -s EXPORTED_RUNTIME_METHODS='["cwrap"]'

参数解释:

  • -o add.js:输出 JS 和 Wasm 文件。
  • -s WASM=1:生成 Wasm。
  • -s EXPORTED_FUNCTIONS:指定要导出的 C 函数(注意前缀下划线)。
  • -s EXPORTED_RUNTIME_METHODS='["cwrap"]':导出 cwrap 工具函数(方便包装)。

输出:add.jsadd.wasm

4. 在 HTML 中调用

<script src="add.js"></script>
<script>
  Module.onRuntimeInitialized = () => {
    const add = Module.cwrap('add', 'number', ['number', 'number']);
    console.log(add(2, 3)); // 5
  };
</script>

注意:必须等待 Module.onRuntimeInitialized,因为 Wasm 是异步加载的。

四、性能实测:斐波那契递归

C++ 版(递归,效率低,放大差距):

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int fib(int n) {
  if (n <= 1) return n;
  return fib(n-1) + fib(n-2);
}

编译后用 JS 实现相同递归。测试 n=45:

  • JS: ~8 秒
  • Wasm: ~3 秒

提升明显。对于非递归计算(循环),差距可能缩小,但 Wasm 依然有优势。

五、在 Rust 中写 Wasm(更现代的选择)

Rust 对 Wasm 支持极好,工具链更简单。安装 wasm-pack

cargo install wasm-pack

创建 lib:

// lib.rs
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

构建:

wasm-pack build --target web

会在 pkg 目录生成 JS 和 Wasm。使用:

import init, { add } from './pkg/my_wasm.js';
async function run() {
  await init();
  console.log(add(2, 3));
}
run();

Rust 方式比 Emscripten 更现代,体积更小。

六、注意事项和坑

  • Wasm 无法直接操作 DOM:你需要把数据计算结果传回 JS,由 JS 更新界面。
  • 文件体积:简单 Wasm 可能只有几十 KB,但引入 Emscripten 的 JS 胶水代码可能上百 KB。Rust 的 wasm-bindgen 生成的胶水代码较小。
  • 数据传输开销:每次调用 Wasm 函数,需要把参数从 JS 拷贝到 Wasm 线性内存,结果再拷贝回来。对于大量数据(如图像),可以用 Module._mallocModule.HEAPU8 共享内存,避免拷贝。
  • 浏览器支持:所有现代浏览器(Chrome、Firefox、Safari、Edge)都支持 Wasm。IE 不支持。

七、实际应用案例

  • Figma:用 Wasm 运行 C++ 图形引擎,实现流畅在线设计。
  • Google Earth:用 Wasm 跑 C++ 3D 渲染。
  • Zoom:网页版使用 Wasm 进行音视频编解码。
  • AutoCAD Web:用 Wasm 把桌面端代码搬到浏览器。

八、总结:Wasm 不是银弹,但是一把“瑞士军刀”

  • 当你遇到 JS 性能瓶颈时,可以考虑 Wasm。
  • 它适合计算密集型,不适合 IO 密集或 DOM 操作。
  • C++ 和 Rust 是最常用的两种源语言,推荐 Rust(安全、工具链友好)。
  • 学习曲线有,但值得投入。

前端开发的未来是多语言协作:JS 负责交互,Wasm 负责计算。两者取长补短,让你的网页应用飞起来。

Webpack vs Vite:一个是“老黄牛”,一个是“猎豹”,你选谁?

你准备搭一个新项目,打开搜索引擎:“Webpack还是Vite?” 答案一半一半,你更懵了。今天我们就来场正面PK:Webpack像头任劳任怨的老黄牛,啥都能干,但起步慢;Vite像只猎豹,瞬间冲刺,但偶尔挑食。看完你就能拍板:我的项目,就该用那个!

前言

前端工具链的“内卷”从未停止。Webpack多年霸主,几乎成了“打包”的代名词。但Vite横空出世,以“快”为刀,砍向Webpack的软肋:开发服务器启动慢、热更新慢。

两者没有绝对好坏,只有合不合适。今天我们从开发体验、生产构建、生态、配置复杂度四个维度,来场硬核对比。

一、核心原理:一个全量打包,一个按需编译

  • Webpack:开发时,从入口开始,递归分析所有模块依赖,打包成一个或多个bundle(哪怕你只用了一个组件,它也把你整个项目打包一遍)。启动慢,但随着项目变大越来越慢。热更新时,需要重新打包变更的模块及其依赖,可能还是慢。

  • Vite:利用浏览器原生ESM(<script type="module">),开发时不打包,只启动一个静态服务器。浏览器请求哪个文件,Vite实时编译哪个文件(比如把JSX转成JS,把TS转成JS)。启动极快(毫秒级),热更新也只更新被改的模块,速度飞快。

比喻:Webpack像搬家公司的卡车,把整个房子家具先打包再运;Vite像快递员,你点一个包裹,他送一个。

二、速度实测:秒开 vs 等咖啡

操作 Webpack Vite
冷启动(大型项目) 10~30秒 <1秒
热更新(改一行代码) 200~500ms(可能更多) <50ms
生产构建 中等(但可优化) 稍慢(用Rollup,但整体可接受)

Vite在开发体验上完胜。尤其大型项目,Webpack启动一次够你刷几条短视频,Vite眨眨眼就好了。

三、生产构建:Webpack还是稳

Vite开发时用ESM,但生产打包不用ESM(会产生太多请求),它底层用的是Rollup。Rollup对tree-shaking、代码分割也很强,但某些复杂场景(比如需要自定义打包逻辑的库)不如Webpack灵活。

Webpack经过多年打磨,插件生态极其丰富,任何你能想到的打包需求(比如特殊文件处理、自定义chunk分割、微前端),Webpack几乎都能找到现成方案。

结论:普通应用项目,Vite的生产构建够用;搞复杂库或需要精细控制打包,Webpack更成熟。

四、配置复杂度:Webpack劝退新手,Vite开箱即用

Webpack配置堪称“噩梦”。从零配置一个支持TypeScript、React、CSS Modules、热更新的项目,要写几十行甚至上百行。虽然官方有create-react-app等脚手架掩盖了配置,但一旦需要 eject 或自定义,头就大了。

Vite默认支持TS、JSX、CSS预处理器、热更新,配置文件极简。你需要做的只是:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

Vite还提供了create-vite脚手架,选择模板一键生成。

五、生态与兼容性:Webpack的护城河

Webpack的插件/loader生态是它的最大优势。比如:

  • file-loader/url-loader 处理静态资源。
  • raw-loader 导入文本。
  • html-webpack-plugin 生成HTML。
  • mini-css-extract-plugin 抽离CSS。
  • webpack-manifest-plugin 生成资源清单。

Vite虽然也支持大多数常见需求(通过插件),但一些老旧的、小众的loader可能没有直接替代。不过对于绝大多数项目,Vite的插件生态已经足够。

另外,Vite要求浏览器支持ESM(现代浏览器都支持),但如果你需要兼容IE11,那不好意思,Vite官方不支持(需要额外插件且很麻烦),这时候Webpack是唯一选择。

六、实战选择:到底用哪个?

用Vite,如果:

  • 新项目,没有历史包袱。
  • 追求极致的开发体验(快!)。
  • 不需要兼容IE11。
  • 项目是常规SPA或静态站点。

用Webpack,如果:

  • 项目已经用Webpack,迁移成本高。
  • 需要兼容IE11。
  • 用了大量Webpack专属插件或自定义loader。
  • 项目是非常复杂的库,需要精细化控制打包。

七、未来趋势:Vite会取代Webpack吗?

短期不会。Webpack在大型企业级项目、复杂构建场景仍有优势。但Vite作为“下一代前端工具链”,已经被Vue、React等官方推荐。尤其在Vue生态,Vite已经是默认配置。

长期看,Vite会逐渐蚕食Webpack在新项目中的份额。但Webpack也不会坐以待毙,Webpack 5 已经改进了缓存和模块联邦,但启动速度这个底层设计问题很难根治。

八、迁移指南:从Webpack到Vite

如果你决定尝鲜,步骤很简单:

  1. create-vite新建一个空项目,复制源码。
  2. require改成import(如果之前用CommonJS)。
  3. 把环境变量从process.env改成import.meta.env
  4. 找对应的Vite插件替代webpack loader。
  5. 测试。

对于中小项目,半天就能完成迁移。

九、总结:没有最好,只有最合适

  • Webpack:老黄牛,稳重、能干、啥都有,但动作慢、配置复杂。
  • Vite:猎豹,快、轻盈、开箱即用,但偶尔挑食(生态稍弱、不支持IE)。

新个人项目、创业项目,无脑上Vite,享受飞一般的开发体验。大厂遗留项目、需要IE兼容,继续Webpack。两者可以共存,甚至可以在一个项目里用Vite开发,Webpack打包(不常见)。

选工具就像选对象,适合的才是最好的。现在你知道该怎么选了。

❌