用 PHP-CPP 開發 PHP 擴充套件:raylib-phpcpp

Oraoto發表於2019-03-17

在上一篇關於FFI的文章中,我嘗試用FFI去呼叫raylib,但是FFI功能還不完善,涉及巢狀結構體、結構體裡有陣列的功能都不能使用,所以我又實現了一個PHP擴充套件:raylib-phpcpp
demo

雖然只是業餘專案,我還是想做得儘量完善,所以定了目標:

  1. 完整支援:主要功能不能缺(目前覆蓋88%的raylib函式,算上PHP有替代品的,覆蓋92%)
  2. 容易維護:raylib新版本釋出時可以儘快跟進
  3. 快速開發:時間不能投入太多,畢竟是業餘專案

下面會看到,我主要通過程式碼生成的方式實現上面這些目標。

開發一個PHP擴充套件,據我所知至少有4種方式:C、Zephir、PHP-X、PHP-CPP。

C的開發難度太高,我也不熟悉Zend API,所以不考慮。

Zephir是類似PHP的語言,可以被編譯成C。Zephir可以通過優化器和CBLOCK呼叫C程式碼,但是這兩種方法還是要用到Zend API,而且沒有簡單的方法去封裝一個C的結構體,所以Zephir被排除掉。

PHP-X和PHP-CPP有點像,都是C++封裝了Zend API,可以用C++實現擴充套件,PHP-CPP功能相對完善,示例也多,所以我最後選擇了PHP-CPP。

我只是看了一下文件和StackOverflow,沒有深入調研每個方案,所以以上資訊很可能不準確。

首先raylib的每一個結構體,我都使用一個類封裝,這些類裡都有一個data屬性,儲存對應的結構體,例如Vector3是:

class Vector3 : public Php::Base {
public:
  ::Vector3 data;
  Vector3(::Vector3 x) { data = x; } // 從raylib的Vector3構造Vector3
  // ... getter and setter
};

在呼叫raylib函式的時候,我可以直接取出data屬性傳過去就可以了,返回結果也類似,直接建構函式儲存到data裡。

有了類還不夠,每個結構體都有很多欄位,例如Vector3xyz,這些欄位的會通過__ get__set方法返回,例如x欄位的getter、setter:

Php::Value getx() {
  double result = data.x;
  return result;
}
void setz(const Php::Value &v) { data.z = (double)v; }

然後註冊到Vector3的裡:

rlVector3.property("x", &Vector3::getx, &Vector3::setx);

對於巢狀的結構體,實現也類似的,例如Camera3D裡有一個Vector3型別的position欄位:

Php::Value getposition() {
  Php::Value result =
      Php::Object("RayLib\\Vector3", new Vector3(data.position));
  return result;
}

void setposition(const Php::Value &v) {
  data.position = ((Vector3 *)(v.implementation()))->data;
}

rlCamera3D.property("position", &Camera3D::getposition, &Camera3D::setposition);

最終效果就是可以在PHP程式碼裡直接構造和訪問結構體的欄位:

$vec = RL::Vector3(1, 2, 3);
$vec->x = $vec->y + 1;

$camera = RL::Camera2D(RL::Vector2(1, 1), RL::Vector2(0, 0), 1.0, 1.0);
echo  $camera->target->x;

對raylib裡的每一個函式,我生成一個對應的方法,這個方法接收一個Php::Value陣列作為引數,陣列裡的每一項會被轉換或者提取出data,然後傳給raylib的函式。

例如DrawPixel:

static void DrawPixel(Php::Parameters &params) {
  int p0 = params[0];  // 標量直接取出
  int p1 = params[1];
  ::Color p2 = ((Color *)(params[2].implementation()))->data; // 結構體提取出data
  ::DrawPixel(p0, p1, p2); // 呼叫raylib的DrawPixel
}

這些方法都是RL類的靜態方法,會被註冊到PHP對應的方法裡:

rlClass.method<&RL::DrawPixel>("DrawPixel");

有些函式需要使用結構體指標,只需在提取data後取址即可:

static void UpdateCamera(Php::Parameters &params) {
  ::Camera3D *p0 = &((Camera3D *)(params[0].implementation()))->data;
  ::UpdateCamera(p0);
}

經過上面兩節的分析,可以發現這裡面有明顯的模式,把全部繫結寫出來只是體力活。對於一個業餘專案,投入這麼多時間去做重複工作實在不值和不爽,所以我直接從raylib的標頭檔案直接生成程式碼。

生成程式碼的第一步是解析raylib.h,這部分工作可以直接使用libclang完成,我用了rust的繫結,使用非常方便:

let clang = Clang::new().unwrap();
let index = Index::new(&clang, false, false);
let tu = index.parser("raylib.h").parse().unwrap();

let mut functions: Vec<FunctionDecl> = Vec::new();
let mut structs: Vec<StructDecl> = Vec::new();

// 遍歷每個entity,收集函式和結構體宣告:
for e in tu.get_entity().get_children().into_iter() {
    if e.get_kind() == EntityKind::FunctionDecl {
        functions.push(FunctionDecl{entity: e});
    }
    if e.get_kind() == EntityKind::StructDecl {
        structs.push(StructDecl{entity: e});
    }
}

接著就是遍歷每一個函式和結構體,按照型別資訊生成程式碼,最終結果是生成了4千行的C++程式碼: raylib.cpp

用程式碼生成的方式,可以很好地完成定下的目標,大部分功能都直接支援,新版本釋出時只需重新生成程式碼,而且工作量比直接手寫少了很多(生成器5百行,生成程式碼4千行),而且非常容易統一新增功能和修改實現。例如我要新增引數型別檢查,可以統一新增,例如改為全域性函式,也只需修改方法的註冊方式。

  1. 有些函式需要使用void *傳陣列引數或返回資料,可以暴露成一個PHP的字串,但是使用者使用不太友好,設想需要一個Typed Array型別,目前沒實現
  2. raylib的字串處理函式沒有繫結,不過PHP自帶的字串函式更完善,直接用PHP就好

下一步就是繼續完善程式碼生成器和做一個小遊戲啦。

相關文章